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

implement basic web ui

evan.jarrett.net 383face7 22d0faa3

verified
+5359 -16
+6 -6
Dockerfile
··· 1 1 # Build stage 2 2 FROM golang:1.24-alpine AS builder 3 3 4 - # Install build dependencies 5 - RUN apk add --no-cache git make 4 + # Install build dependencies (gcc and musl-dev needed for SQLite CGO) 5 + RUN apk add --no-cache git make gcc musl-dev sqlite-dev 6 6 7 7 # Set working directory 8 8 WORKDIR /build ··· 16 16 # Copy source code 17 17 COPY . . 18 18 19 - # Build the binary 20 - RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o atcr-registry ./cmd/registry 19 + # Build the binary with CGO enabled for SQLite support 20 + RUN CGO_ENABLED=1 GOOS=linux go build -a -o atcr-registry ./cmd/registry 21 21 22 22 # Runtime stage 23 23 FROM alpine:latest 24 24 25 - # Install CA certificates for HTTPS 26 - RUN apk --no-cache add ca-certificates 25 + # Install CA certificates for HTTPS and SQLite runtime libraries 26 + RUN apk --no-cache add ca-certificates sqlite-libs 27 27 28 28 # Set working directory 29 29 WORKDIR /app
SAILOR.md docs/SAILOR.md
TESTING.md docs/TESTING.md
+142 -2
cmd/registry/serve.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "fmt" 7 + "html/template" 6 8 "net/http" 7 9 "os" 8 10 "os/signal" ··· 20 22 "atcr.io/pkg/auth/session" 21 23 "atcr.io/pkg/auth/token" 22 24 "atcr.io/pkg/middleware" 25 + 26 + // UI components 27 + "atcr.io/pkg/appview" 28 + "atcr.io/pkg/appview/db" 29 + uihandlers "atcr.io/pkg/appview/handlers" 30 + appmiddleware "atcr.io/pkg/appview/middleware" 31 + appsession "atcr.io/pkg/appview/session" 32 + "github.com/gorilla/mux" 23 33 ) 24 34 25 35 var serveCmd = &cobra.Command{ ··· 116 126 // 5. Set global refresher for middleware 117 127 middleware.SetGlobalRefresher(refresher) 118 128 119 - // 6. Create OAuth server 129 + // 6. Initialize UI components (get session store for OAuth integration) 130 + uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config) 131 + 132 + // 7. Create OAuth server 120 133 oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL) 121 134 // Connect server to refresher for cache invalidation 122 135 oauthServer.SetRefresher(refresher) 136 + // Connect UI session store for web login 137 + if uiSessionStore != nil { 138 + oauthServer.SetUISessionStore(uiSessionStore) 139 + } 123 140 124 - // 7. Initialize auth keys and create token issuer 141 + // 8. Initialize auth keys and create token issuer 125 142 var issuer *token.Issuer 126 143 if config.Auth["token"] != nil { 127 144 if err := initializeAuthKeys(config); err != nil { ··· 144 161 145 162 // Mount registry at /v2/ 146 163 mux.Handle("/v2/", app) 164 + 165 + // Mount UI routes if enabled 166 + if uiDatabase != nil && uiSessionStore != nil && uiTemplates != nil && uiRouter != nil { 167 + // Mount static files 168 + mux.Handle("/static/", http.StripPrefix("/static/", appview.StaticHandler())) 169 + 170 + // Mount UI routes directly at root level 171 + mux.Handle("/", uiRouter) 172 + 173 + fmt.Printf("UI enabled:\n") 174 + fmt.Printf(" - Home: /\n") 175 + fmt.Printf(" - Images: /images\n") 176 + fmt.Printf(" - Settings: /settings\n") 177 + } 147 178 148 179 // Mount OAuth endpoints 149 180 mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize) ··· 305 336 306 337 return "" 307 338 } 339 + 340 + // initializeUI initializes the web UI components 341 + func initializeUI(config *configuration.Configuration) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { 342 + // Check if UI is enabled (optional configuration) 343 + uiEnabled := os.Getenv("ATCR_UI_ENABLED") 344 + if uiEnabled == "false" { 345 + return nil, nil, nil, nil 346 + } 347 + 348 + // Get database path 349 + dbPath := os.Getenv("ATCR_UI_DATABASE_PATH") 350 + if dbPath == "" { 351 + dbPath = "/var/lib/atcr/ui.db" 352 + } 353 + 354 + // Ensure directory exists 355 + dbDir := filepath.Dir(dbPath) 356 + if err := os.MkdirAll(dbDir, 0700); err != nil { 357 + fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 358 + return nil, nil, nil, nil 359 + } 360 + 361 + // Initialize database 362 + database, err := db.InitDB(dbPath) 363 + if err != nil { 364 + fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 365 + return nil, nil, nil, nil 366 + } 367 + 368 + fmt.Printf("UI database initialized at %s\n", dbPath) 369 + 370 + // Create session store 371 + sessionStore := appsession.NewStore() 372 + 373 + // Start cleanup goroutine 374 + go func() { 375 + ticker := time.NewTicker(5 * time.Minute) 376 + defer ticker.Stop() 377 + for range ticker.C { 378 + sessionStore.Cleanup() 379 + } 380 + }() 381 + 382 + // Load templates 383 + templates, err := appview.Templates() 384 + if err != nil { 385 + fmt.Printf("Warning: Failed to load UI templates: %v\n", err) 386 + return nil, nil, nil, nil 387 + } 388 + 389 + // Create router 390 + router := mux.NewRouter() 391 + 392 + // OAuth login routes (public) 393 + router.Handle("/auth/oauth/login", &uihandlers.LoginHandler{ 394 + Templates: templates, 395 + }).Methods("GET") 396 + 397 + router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{}).Methods("POST") 398 + 399 + // Public routes (with optional auth for navbar) 400 + router.Handle("/", appmiddleware.OptionalAuth(sessionStore)( 401 + &uihandlers.HomeHandler{ 402 + DB: database, 403 + Templates: templates, 404 + }, 405 + )).Methods("GET") 406 + 407 + router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore)( 408 + &uihandlers.RecentPushesHandler{ 409 + DB: database, 410 + Templates: templates, 411 + }, 412 + )).Methods("GET") 413 + 414 + // Authenticated routes 415 + authRouter := router.NewRoute().Subrouter() 416 + authRouter.Use(appmiddleware.RequireAuth(sessionStore)) 417 + 418 + authRouter.Handle("/images", &uihandlers.ImagesHandler{ 419 + DB: database, 420 + Templates: templates, 421 + }).Methods("GET") 422 + 423 + authRouter.Handle("/settings", &uihandlers.SettingsHandler{ 424 + Templates: templates, 425 + }).Methods("GET") 426 + 427 + authRouter.Handle("/api/profile/default-hold", &uihandlers.UpdateDefaultHoldHandler{}).Methods("POST") 428 + 429 + authRouter.Handle("/api/images/{repository}/tags/{tag}", &uihandlers.DeleteTagHandler{ 430 + DB: database, 431 + }).Methods("DELETE") 432 + 433 + authRouter.Handle("/api/images/{repository}/manifests/{digest}", &uihandlers.DeleteManifestHandler{ 434 + DB: database, 435 + }).Methods("DELETE") 436 + 437 + // Logout endpoint 438 + router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { 439 + if sessionID, ok := appsession.GetSessionID(r); ok { 440 + sessionStore.Delete(sessionID) 441 + } 442 + appsession.ClearCookie(w) 443 + http.Redirect(w, r, "/", http.StatusFound) 444 + }).Methods("POST") 445 + 446 + return database, sessionStore, templates, router 447 + }
+4
docker-compose.yml
··· 9 9 - "5000:5000" 10 10 environment: 11 11 - ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json 12 + - ATCR_UI_ENABLED=true 12 13 volumes: 13 14 # Auth keys (JWT signing keys) 14 15 - atcr-auth:/var/lib/atcr/auth 15 16 # OAuth refresh tokens (persists user sessions across container restarts) 16 17 - atcr-tokens:/var/lib/atcr/tokens 18 + # UI database (firehose cache for web interface) 19 + - atcr-ui:/var/lib/atcr 17 20 restart: unless-stopped 18 21 networks: 19 22 atcr-network: ··· 57 60 atcr-hold: 58 61 atcr-auth: 59 62 atcr-tokens: 63 + atcr-ui:
+623
docs/APPVIEW-UI-FUTURE.md
··· 1 + # ATCR AppView UI - Future Features 2 + 3 + This document outlines potential features for future versions of the ATCR AppView UI, beyond the V1 MVP. These are ideas to consider as the project matures and user needs evolve. 4 + 5 + ## Advanced Image Management 6 + 7 + ### Multi-Architecture Image Support 8 + 9 + **Display image indexes:** 10 + - Show when a tag points to an image index (multi-arch manifest) 11 + - Display all architectures/platforms in the index (linux/amd64, linux/arm64, darwin/arm64, etc.) 12 + - Allow viewing individual manifests within the index 13 + - Show platform-specific layer details 14 + 15 + **Image index creation:** 16 + - UI for combining multiple single-arch manifests into an image index 17 + - Automatic platform detection from manifest metadata 18 + - Validate that all manifests are for the same image (different platforms) 19 + 20 + ### Layer Inspection & Visualization 21 + 22 + **Layer details page:** 23 + - Show Dockerfile command that created each layer (if available in history) 24 + - Display layer size and compression ratio 25 + - Show file changes in each layer (added/modified/deleted files) 26 + - Visualize layer hierarchy (parent-child relationships) 27 + 28 + **Layer deduplication stats:** 29 + - Show which layers are shared across images 30 + - Calculate storage savings from layer sharing 31 + - Identify duplicate layers with different digests (potential optimization) 32 + 33 + ### Image Operations 34 + 35 + **Tag Management:** 36 + - **Tag promotion workflow:** dev → staging → prod with one click 37 + - **Tag aliases:** Create multiple tags pointing to same digest 38 + - **Tag patterns:** Auto-tag based on git commit, semantic version, date 39 + - **Tag protection:** Mark tags as immutable (prevent deletion/re-pointing) 40 + 41 + **Image Copying:** 42 + - Copy image from one repository to another 43 + - Copy image from another user's repository (fork) 44 + - Bulk copy operations (copy all tags, copy all manifests) 45 + 46 + **Image History:** 47 + - Timeline view of tag changes (what digest did "latest" point to over time) 48 + - Rollback functionality (revert tag to previous digest) 49 + - Audit log of all image operations (push, delete, tag changes) 50 + 51 + ### Vulnerability Scanning 52 + 53 + **Integration with security scanners:** 54 + - **Trivy** - Comprehensive vulnerability scanner 55 + - **Grype** - Anchore's vulnerability scanner 56 + - **Clair** - CoreOS vulnerability scanner 57 + 58 + **Features:** 59 + - Automatic scanning on image push 60 + - Display CVE count by severity (critical, high, medium, low) 61 + - Show detailed CVE information (description, CVSS score, affected packages) 62 + - Filter images by vulnerability status 63 + - Subscribe to CVE notifications for your images 64 + - Compare vulnerability status across tags/versions 65 + 66 + ### Image Signing & Verification 67 + 68 + **Cosign/Sigstore integration:** 69 + - Sign images with Cosign 70 + - Display signature verification status 71 + - Show keyless signing certificate chains 72 + - Integrate with transparency log (Rekor) 73 + 74 + **Features:** 75 + - UI for signing images (generate key, sign manifest) 76 + - Verify signatures before pull (browser-based verification) 77 + - Display signature metadata (signer, timestamp, transparency log entry) 78 + - Require signatures for protected repositories 79 + 80 + ### SBOM (Software Bill of Materials) 81 + 82 + **SBOM generation and display:** 83 + - Generate SBOM on push (SPDX or CycloneDX format) 84 + - Display package list from SBOM 85 + - Show license information 86 + - Link to upstream package sources 87 + - Compare SBOMs across versions (what packages changed) 88 + 89 + **SBOM attestation:** 90 + - Store SBOM as attestation (in-toto format) 91 + - Link SBOM to image signature 92 + - Verify SBOM integrity 93 + 94 + ## Hold Management Dashboard 95 + 96 + ### Hold Discovery & Registration 97 + 98 + **Create hold:** 99 + - UI wizard for deploying hold service 100 + - One-click deployment to Fly.io, Railway, Render 101 + - Configuration generator (environment variables, docker-compose) 102 + - Test connectivity after deployment 103 + 104 + **Hold registration:** 105 + - Automatic registration via OAuth (already implemented) 106 + - Manual registration form (for existing holds) 107 + - Bulk import holds from JSON/YAML 108 + 109 + ### Hold Configuration 110 + 111 + **Hold settings page:** 112 + - Edit hold metadata (name, description, icon) 113 + - Toggle public/private flag 114 + - Configure storage backend (S3, Storj, Minio, filesystem) 115 + - Set storage quotas and limits 116 + - Configure retention policies (auto-delete old blobs) 117 + 118 + **Hold credentials:** 119 + - Rotate S3 access keys 120 + - Test hold connectivity 121 + - View hold service logs (if accessible) 122 + 123 + ### Crew Management 124 + 125 + **Invite crew members:** 126 + - Send invitation links (OAuth-based) 127 + - Invite by handle or DID 128 + - Set crew permissions (read-only, read-write, admin) 129 + - Bulk invite (upload CSV) 130 + 131 + **Crew list:** 132 + - Display all crew members 133 + - Show last activity (last push, last pull) 134 + - Remove crew members 135 + - Change crew permissions 136 + 137 + **Crew request workflow:** 138 + - Allow users to request access to a hold 139 + - Hold owner approves/rejects requests 140 + - Notification system for requests 141 + 142 + ### Hold Analytics 143 + 144 + **Storage metrics:** 145 + - Total storage used (bytes) 146 + - Blob count 147 + - Largest blobs 148 + - Growth over time (chart) 149 + - Deduplication savings 150 + 151 + **Access metrics:** 152 + - Total downloads (pulls) 153 + - Bandwidth used 154 + - Popular images (most pulled) 155 + - Geographic distribution (if available) 156 + - Access logs (who pulled what, when) 157 + 158 + **Cost estimation:** 159 + - Calculate S3 storage costs 160 + - Calculate bandwidth costs 161 + - Compare costs across storage backends 162 + - Budget alerts (notify when approaching limit) 163 + 164 + ## Discovery & Social Features 165 + 166 + ### Federated Browse & Search 167 + 168 + **Enhanced discovery:** 169 + - Full-text search across all ATCR images (repository name, tag, description) 170 + - Filter by user, hold, architecture, date range 171 + - Sort by popularity, recency, size 172 + - Advanced query syntax (e.g., "user:alice tag:latest arch:arm64") 173 + 174 + **Popular/Trending:** 175 + - Most pulled images (past day, week, month) 176 + - Fastest growing images (new pulls) 177 + - Recently updated images (new tags) 178 + - Community favorites (curated list) 179 + 180 + **Categories & Tags:** 181 + - User-defined categories (web, database, ml, etc.) 182 + - Tag images with keywords (nginx, proxy, reverse-proxy) 183 + - Browse by category 184 + - Tag cloud visualization 185 + 186 + ### Sailor Profiles (Public) 187 + 188 + **Public profile page:** 189 + - `/ui/@alice` shows alice's public repositories 190 + - Bio, avatar, website links 191 + - Statistics (total images, total pulls, joined date) 192 + - Pinned repositories (showcase best images) 193 + 194 + **Social features:** 195 + - Follow other sailors (get notified of their pushes) 196 + - Star repositories (bookmark favorites) 197 + - Comment on images (feedback, questions) 198 + - Like/upvote images 199 + 200 + **Activity feed:** 201 + - Timeline of followed sailors' activity 202 + - Recent pushes from community 203 + - Popular images from followed users 204 + 205 + ### Federated Timeline 206 + 207 + **ATProto-native feed:** 208 + - Real-time feed of container pushes (like Bluesky's timeline) 209 + - Filter by follows, community, or global 210 + - React to pushes (like, share, comment) 211 + - Share images to Bluesky/ATProto social apps 212 + 213 + **Custom feeds:** 214 + - Create algorithmic feeds (e.g., "Show me all ML images") 215 + - Subscribe to curated feeds 216 + - Publish feeds for others to subscribe 217 + 218 + ## Access Control & Permissions 219 + 220 + ### Repository-Level Permissions 221 + 222 + **Private repositories:** 223 + - Mark repositories as private (only owner + collaborators can pull) 224 + - Invite collaborators by handle/DID 225 + - Set permissions (read-only, read-write, admin) 226 + 227 + **Public repositories:** 228 + - Default: public (anyone can pull) 229 + - Require authentication for private repos 230 + - Generate read-only tokens (for CI/CD) 231 + 232 + **Implementation challenge:** 233 + - ATProto doesn't support private records yet 234 + - May require proxy layer for access control 235 + - Or use encrypted blobs with shared keys 236 + 237 + ### Team/Organization Accounts 238 + 239 + **Multi-user organizations:** 240 + - Create organization account (e.g., `@acme-corp`) 241 + - Add members with roles (owner, maintainer, member) 242 + - Organization-owned repositories 243 + - Billing and quotas at org level 244 + 245 + **Features:** 246 + - Team-based access control 247 + - Shared hold for organization 248 + - Audit logs for all org activity 249 + - Single sign-on (SSO) integration 250 + 251 + ## Analytics & Monitoring 252 + 253 + ### Dashboard 254 + 255 + **Personal dashboard:** 256 + - Overview of your images, holds, activity 257 + - Quick stats (total size, pull count, last push) 258 + - Recent activity (your pushes, pulls) 259 + - Alerts and notifications 260 + 261 + **Hold dashboard:** 262 + - Storage usage, bandwidth, costs 263 + - Active crew members 264 + - Recent uploads/downloads 265 + - Health status of hold service 266 + 267 + ### Pull Analytics 268 + 269 + **Detailed metrics:** 270 + - Pull count per image/tag 271 + - Pull count by client (Docker, containerd, podman) 272 + - Pull count by geography (country, region) 273 + - Pull count over time (chart) 274 + - Failed pulls (errors, retries) 275 + 276 + **User analytics:** 277 + - Who is pulling your images (if authenticated) 278 + - Anonymous vs authenticated pulls 279 + - Repeat users vs new users 280 + 281 + ### Alerts & Notifications 282 + 283 + **Alert types:** 284 + - Storage quota exceeded 285 + - High bandwidth usage 286 + - New vulnerability detected 287 + - Image signature invalid 288 + - Hold service down 289 + - Crew member joined/left 290 + 291 + **Notification channels:** 292 + - Email 293 + - Webhook (POST to custom URL) 294 + - ATProto app notification (future: in-app notifications in Bluesky) 295 + - Slack, Discord, Telegram integrations 296 + 297 + ## Developer Tools & Integrations 298 + 299 + ### API Documentation 300 + 301 + **Interactive API docs:** 302 + - Swagger/OpenAPI spec for OCI API 303 + - Swagger/OpenAPI spec for UI API 304 + - Interactive API explorer (try API calls in browser) 305 + - Code examples in multiple languages (curl, Go, Python, JavaScript) 306 + 307 + **SDK/Client Libraries:** 308 + - Official Go client library 309 + - JavaScript/TypeScript client 310 + - Python client 311 + - Rust client 312 + 313 + ### Webhooks 314 + 315 + **Webhook configuration:** 316 + - Register webhook URLs per repository 317 + - Select events to trigger (push, delete, tag update) 318 + - Test webhooks (send test payload) 319 + - View webhook delivery history 320 + - Retry failed deliveries 321 + 322 + **Webhook events:** 323 + - `manifest.pushed` 324 + - `manifest.deleted` 325 + - `tag.created` 326 + - `tag.updated` 327 + - `tag.deleted` 328 + - `scan.completed` (vulnerability scan finished) 329 + 330 + ### CI/CD Integration Guides 331 + 332 + **Documentation for popular CI/CD platforms:** 333 + - GitHub Actions (example workflows) 334 + - GitLab CI (.gitlab-ci.yml examples) 335 + - CircleCI (config.yml examples) 336 + - Jenkins (Jenkinsfile examples) 337 + - Drone CI 338 + 339 + **Features:** 340 + - One-click workflow generation 341 + - Pre-built actions/plugins for ATCR 342 + - Cache layer optimization for faster builds 343 + - Build status badges (show build status in README) 344 + 345 + ### Infrastructure as Code 346 + 347 + **IaC examples:** 348 + - Terraform module for deploying hold service 349 + - Pulumi program for ATCR infrastructure 350 + - Kubernetes manifests for hold service 351 + - Docker Compose for local development 352 + - Helm chart for AppView + hold 353 + 354 + **GitOps workflows:** 355 + - ArgoCD integration (deploy images from ATCR) 356 + - FluxCD integration 357 + - Automated deployments on tag push 358 + 359 + ## Documentation & Onboarding 360 + 361 + ### Interactive Getting Started 362 + 363 + **Onboarding wizard:** 364 + - Step-by-step guide for first-time users 365 + - Interactive tutorial (push your first image) 366 + - Verify setup (test authentication, test push/pull) 367 + - Completion checklist 368 + 369 + **Guided tours:** 370 + - Product tour of UI features 371 + - Tooltips and hints for new users 372 + - Help center with FAQs 373 + 374 + ### Comprehensive Documentation 375 + 376 + **Documentation sections:** 377 + - Quickstart guide 378 + - Detailed user manual 379 + - API reference 380 + - ATProto record schemas 381 + - Deployment guides (hold service, AppView) 382 + - Troubleshooting guide 383 + - Security best practices 384 + 385 + **Video tutorials:** 386 + - YouTube channel with how-to videos 387 + - Screen recordings of common tasks 388 + - Conference talks and demos 389 + 390 + ### Community & Support 391 + 392 + **Community features:** 393 + - Discussion forum (or integrate with Discourse) 394 + - GitHub Discussions for ATCR project 395 + - Discord/Slack community 396 + - Monthly community calls 397 + 398 + **Support channels:** 399 + - Email support 400 + - Live chat (for paid tiers) 401 + - Priority support (for enterprise) 402 + 403 + ## Advanced ATProto Integration 404 + 405 + ### Record Viewer 406 + 407 + **ATProto record browser:** 408 + - Browse all your `io.atcr.*` records 409 + - Raw JSON view with ATProto metadata (CID, commit info, timestamp) 410 + - Diff viewer for record updates 411 + - History view (see all versions of a record) 412 + - Link to ATP URI (`at://did/collection/rkey`) 413 + 414 + **Export/Import:** 415 + - Export all records as JSON (backup) 416 + - Import records from JSON (restore, migration) 417 + - CAR file export (ATProto native format) 418 + 419 + ### PDS Integration 420 + 421 + **Multi-PDS support:** 422 + - Switch between multiple PDS accounts 423 + - Manage images across different PDSs 424 + - Unified view of all your images (across PDSs) 425 + 426 + **PDS health monitoring:** 427 + - Show PDS connection status 428 + - Alert if PDS is unreachable 429 + - Fallback to alternate PDS (if configured) 430 + 431 + **PDS migration tools:** 432 + - Migrate images from one PDS to another 433 + - Bulk update hold endpoints 434 + - Re-sign OAuth tokens for new PDS 435 + 436 + ### Decentralization Features 437 + 438 + **Data sovereignty:** 439 + - "Verify on PDS" button (proves manifest is in your PDS) 440 + - "Clone my registry" guide (backup to another PDS) 441 + - "Export registry" (download all manifests + metadata) 442 + 443 + **Federation:** 444 + - Cross-AppView image pulls (pull from other ATCR AppViews) 445 + - AppView discovery (find other ATCR instances) 446 + - Federated search (search across multiple AppViews) 447 + 448 + ## Enterprise Features (Future Commercial Offering) 449 + 450 + ### Team Collaboration 451 + 452 + **Organizations:** 453 + - Enterprise org accounts with unlimited members 454 + - RBAC (role-based access control) 455 + - SSO integration (SAML, OIDC) 456 + - Audit logs for compliance 457 + 458 + ### Compliance & Security 459 + 460 + **Compliance tools:** 461 + - SOC 2 compliance reporting 462 + - HIPAA-compliant storage options 463 + - GDPR data export/deletion 464 + - Retention policies (auto-delete after N days) 465 + 466 + **Security features:** 467 + - Image scanning with policy enforcement (block vulnerable images) 468 + - Malware scanning (scan blobs for malware) 469 + - Secrets scanning (detect leaked credentials in layers) 470 + - Content trust (require signed images) 471 + 472 + ### SLA & Support 473 + 474 + **Paid tiers:** 475 + - Free tier: 5GB storage, community support 476 + - Pro tier: 100GB storage, email support, SLA 477 + - Enterprise tier: Unlimited storage, priority support, dedicated instance 478 + 479 + **Features:** 480 + - Guaranteed uptime (99.9%) 481 + - Premium support (24/7, faster response) 482 + - Dedicated account manager 483 + - Custom contract terms 484 + 485 + ## UI/UX Enhancements 486 + 487 + ### Design System 488 + 489 + **Theming:** 490 + - Light and dark modes (system preference) 491 + - Custom themes (nautical, cyberpunk, minimalist) 492 + - Accessibility (WCAG 2.1 AA compliance) 493 + - High contrast mode 494 + 495 + **Responsive design:** 496 + - Mobile-first design 497 + - Progressive web app (PWA) with offline support 498 + - Native mobile apps (iOS, Android) 499 + 500 + ### Performance Optimizations 501 + 502 + **Frontend optimizations:** 503 + - Lazy loading for images and data 504 + - Virtual scrolling for large lists 505 + - Service worker for caching 506 + - Code splitting (load only what's needed) 507 + 508 + **Backend optimizations:** 509 + - GraphQL API (fetch only required fields) 510 + - Real-time updates via WebSocket 511 + - Server-sent events for firehose 512 + - Edge caching (CloudFlare, Fastly) 513 + 514 + ### Internationalization 515 + 516 + **Multi-language support:** 517 + - UI translations (English, Spanish, French, German, Japanese, Chinese, etc.) 518 + - RTL (right-to-left) language support 519 + - Localized date/time formats 520 + - Locale-specific formatting (numbers, currencies) 521 + 522 + ## Miscellaneous Ideas 523 + 524 + ### Image Build Service 525 + 526 + **Cloud-based builds:** 527 + - Build images from Dockerfile in the UI 528 + - Multi-stage build support 529 + - Build cache optimization 530 + - Build logs and status 531 + 532 + **Automated builds:** 533 + - Connect GitHub/GitLab repository 534 + - Auto-build on git push 535 + - Build matrix (multiple architectures, versions) 536 + - Build notifications 537 + 538 + ### Image Registry Mirroring 539 + 540 + **Mirror external registries:** 541 + - Cache images from Docker Hub, ghcr.io, quay.io 542 + - Transparent proxy (pull-through cache) 543 + - Reduce external bandwidth costs 544 + - Faster pulls (cache locally) 545 + 546 + **Features:** 547 + - Configurable cache retention 548 + - Whitelist/blacklist registries 549 + - Statistics (cache hit rate, savings) 550 + 551 + ### Deployment Tools 552 + 553 + **One-click deployments:** 554 + - Deploy image to Kubernetes 555 + - Deploy to Docker Swarm 556 + - Deploy to AWS ECS/Fargate 557 + - Deploy to Fly.io, Railway, Render 558 + 559 + **Deployment tracking:** 560 + - Track where images are deployed 561 + - Show running versions (which environments use which tags) 562 + - Notify on new deployments 563 + 564 + ### Image Recommendations 565 + 566 + **ML-based recommendations:** 567 + - "Similar images" (based on layers, packages, tags) 568 + - "People who pulled this also pulled..." (collaborative filtering) 569 + - "Recommended for you" (personalized based on history) 570 + 571 + ### Gamification 572 + 573 + **Achievements:** 574 + - Badges for milestones (first push, 100 pulls, 1GB storage, etc.) 575 + - Leaderboards (most popular images, most active sailors) 576 + - Community contributions (points for helping others) 577 + 578 + ### Advanced Search 579 + 580 + **Semantic search:** 581 + - Search by description, README, labels 582 + - Natural language queries ("show me nginx images with SSL") 583 + - AI-powered search (GPT-based understanding) 584 + 585 + **Saved searches:** 586 + - Save frequently used queries 587 + - Subscribe to search results (get notified of new matches) 588 + - Share searches with team 589 + 590 + ## Implementation Priority 591 + 592 + If implementing these features, suggested priority order: 593 + 594 + **High Priority (Next 6 months):** 595 + 1. Multi-architecture image support 596 + 2. Vulnerability scanning integration 597 + 3. Hold management dashboard 598 + 4. Enhanced search and filtering 599 + 5. Webhooks for CI/CD integration 600 + 601 + **Medium Priority (6-12 months):** 602 + 1. Team/organization accounts 603 + 2. Repository-level permissions 604 + 3. Image signing and verification 605 + 4. Pull analytics and monitoring 606 + 5. API documentation and SDKs 607 + 608 + **Low Priority (12+ months):** 609 + 1. Enterprise features (SSO, compliance, SLA) 610 + 2. Image build service 611 + 3. Registry mirroring 612 + 4. Mobile apps 613 + 5. ML-based recommendations 614 + 615 + **Research/Experimental:** 616 + 1. Private repositories (requires ATProto private records) 617 + 2. Federated timeline (requires ATProto feed infrastructure) 618 + 3. Deployment tools integration 619 + 4. Semantic search 620 + 621 + --- 622 + 623 + **Note:** This is a living document. Features may be added, removed, or reprioritized based on user feedback, technical feasibility, and ATProto ecosystem evolution.
+1827
docs/APPVIEW-UI-IMPLEMENTATION.md
··· 1 + # ATCR AppView UI - Implementation Guide 2 + 3 + This document provides step-by-step implementation details for building the ATCR web UI using **html/template + HTMX**. 4 + 5 + ## Tech Stack (Finalized) 6 + 7 + - **Backend:** Go (existing AppView) 8 + - **Templates:** `html/template` (standard library) 9 + - **Interactivity:** HTMX (~14KB) + Alpine.js (~15KB, optional) 10 + - **Database:** SQLite (firehose cache) 11 + - **Styling:** Simple CSS or Tailwind (TBD) 12 + - **Authentication:** OAuth (existing implementation) 13 + 14 + ## Project Structure 15 + 16 + ``` 17 + cmd/registry/ 18 + ├── main.go # Add AppView routes here 19 + 20 + pkg/appview/ 21 + ├── appview.go # Main AppView setup, embed directives 22 + ├── handlers/ # HTTP handlers 23 + │ ├── home.go # Front page (firehose) 24 + │ ├── settings.go # Settings page 25 + │ ├── images.go # Personal images page 26 + │ └── auth.go # Login/logout handlers 27 + ├── db/ # Database layer 28 + │ ├── schema.go # SQLite schema 29 + │ ├── queries.go # DB queries 30 + │ └── models.go # Data models 31 + ├── firehose/ # Firehose worker 32 + │ ├── worker.go # Background worker 33 + │ └── jetstream.go # Jetstream client 34 + ├── middleware/ # HTTP middleware 35 + │ ├── auth.go # Session auth 36 + │ └── csrf.go # CSRF protection 37 + ├── session/ # Session management 38 + │ └── session.go # Session store 39 + ├── templates/ # HTML templates (embedded) 40 + │ ├── layouts/ 41 + │ │ └── base.html # Base layout 42 + │ ├── components/ 43 + │ │ ├── nav.html # Navigation bar 44 + │ │ └── modal.html # Modal dialogs 45 + │ ├── pages/ 46 + │ │ ├── home.html # Front page 47 + │ │ ├── settings.html # Settings page 48 + │ │ └── images.html # Personal images 49 + │ └── partials/ # HTMX partials 50 + │ ├── push-list.html # Push list partial 51 + │ └── tag-row.html # Tag row partial 52 + └── static/ # Static assets (embedded) 53 + ├── css/ 54 + │ └── style.css 55 + └── js/ 56 + └── app.js # Minimal JS (clipboard, etc.) 57 + ``` 58 + 59 + ## Step 1: Embed Setup 60 + 61 + ### Main AppView Package 62 + 63 + **pkg/appview/appview.go:** 64 + 65 + ```go 66 + package appview 67 + 68 + import ( 69 + "embed" 70 + "html/template" 71 + "io/fs" 72 + "net/http" 73 + ) 74 + 75 + //go:embed templates/*.html templates/**/*.html 76 + var templatesFS embed.FS 77 + 78 + //go:embed static/* 79 + var staticFS embed.FS 80 + 81 + // Templates returns parsed templates 82 + func Templates() (*template.Template, error) { 83 + return template.ParseFS(templatesFS, "templates/**/*.html") 84 + } 85 + 86 + // StaticHandler returns HTTP handler for static files 87 + func StaticHandler() http.Handler { 88 + sub, _ := fs.Sub(staticFS, "static") 89 + return http.FileServer(http.FS(sub)) 90 + } 91 + ``` 92 + 93 + ## Step 2: Database Setup 94 + 95 + ### Create Schema 96 + 97 + **pkg/appview/db/schema.go:** 98 + 99 + ```go 100 + package db 101 + 102 + import ( 103 + "database/sql" 104 + _ "github.com/mattn/go-sqlite3" 105 + ) 106 + 107 + const schema = ` 108 + CREATE TABLE IF NOT EXISTS users ( 109 + did TEXT PRIMARY KEY, 110 + handle TEXT NOT NULL, 111 + pds_endpoint TEXT NOT NULL, 112 + last_seen TIMESTAMP NOT NULL, 113 + UNIQUE(handle) 114 + ); 115 + CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); 116 + 117 + CREATE TABLE IF NOT EXISTS manifests ( 118 + id INTEGER PRIMARY KEY AUTOINCREMENT, 119 + did TEXT NOT NULL, 120 + repository TEXT NOT NULL, 121 + digest TEXT NOT NULL, 122 + hold_endpoint TEXT NOT NULL, 123 + schema_version INTEGER NOT NULL, 124 + media_type TEXT NOT NULL, 125 + config_digest TEXT, 126 + config_size INTEGER, 127 + raw_manifest TEXT NOT NULL, 128 + created_at TIMESTAMP NOT NULL, 129 + UNIQUE(did, repository, digest), 130 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 131 + ); 132 + CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 133 + CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 134 + CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 135 + 136 + CREATE TABLE IF NOT EXISTS layers ( 137 + manifest_id INTEGER NOT NULL, 138 + digest TEXT NOT NULL, 139 + size INTEGER NOT NULL, 140 + media_type TEXT NOT NULL, 141 + layer_index INTEGER NOT NULL, 142 + PRIMARY KEY(manifest_id, layer_index), 143 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 144 + ); 145 + CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); 146 + 147 + CREATE TABLE IF NOT EXISTS tags ( 148 + id INTEGER PRIMARY KEY AUTOINCREMENT, 149 + did TEXT NOT NULL, 150 + repository TEXT NOT NULL, 151 + tag TEXT NOT NULL, 152 + digest TEXT NOT NULL, 153 + created_at TIMESTAMP NOT NULL, 154 + UNIQUE(did, repository, tag), 155 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 156 + ); 157 + CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository); 158 + 159 + CREATE TABLE IF NOT EXISTS firehose_cursor ( 160 + id INTEGER PRIMARY KEY CHECK (id = 1), 161 + cursor INTEGER NOT NULL, 162 + updated_at TIMESTAMP NOT NULL 163 + ); 164 + ` 165 + 166 + func InitDB(path string) (*sql.DB, error) { 167 + db, err := sql.Open("sqlite3", path) 168 + if err != nil { 169 + return nil, err 170 + } 171 + 172 + if _, err := db.Exec(schema); err != nil { 173 + return nil, err 174 + } 175 + 176 + return db, nil 177 + } 178 + ``` 179 + 180 + ### Data Models 181 + 182 + **pkg/appview/db/models.go:** 183 + 184 + ```go 185 + package db 186 + 187 + import "time" 188 + 189 + type User struct { 190 + DID string 191 + Handle string 192 + PDSEndpoint string 193 + LastSeen time.Time 194 + } 195 + 196 + type Manifest struct { 197 + ID int64 198 + DID string 199 + Repository string 200 + Digest string 201 + HoldEndpoint string 202 + SchemaVersion int 203 + MediaType string 204 + ConfigDigest string 205 + ConfigSize int64 206 + RawManifest string // JSON 207 + CreatedAt time.Time 208 + } 209 + 210 + type Tag struct { 211 + ID int64 212 + DID string 213 + Repository string 214 + Tag string 215 + Digest string 216 + CreatedAt time.Time 217 + } 218 + 219 + type Push struct { 220 + Handle string 221 + Repository string 222 + Tag string 223 + Digest string 224 + HoldEndpoint string 225 + CreatedAt time.Time 226 + } 227 + 228 + type Repository struct { 229 + Name string 230 + TagCount int 231 + ManifestCount int 232 + LastPush time.Time 233 + Tags []Tag 234 + Manifests []Manifest 235 + } 236 + ``` 237 + 238 + ### Query Functions 239 + 240 + **pkg/appview/db/queries.go:** 241 + 242 + ```go 243 + package db 244 + 245 + import ( 246 + "database/sql" 247 + "time" 248 + ) 249 + 250 + // GetRecentPushes fetches recent pushes with pagination 251 + func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { 252 + query := ` 253 + SELECT u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at 254 + FROM tags t 255 + JOIN users u ON t.did = u.did 256 + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 257 + ` 258 + 259 + if userFilter != "" { 260 + query += " WHERE u.handle = ? OR u.did = ?" 261 + } 262 + 263 + query += " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" 264 + 265 + var rows *sql.Rows 266 + var err error 267 + 268 + if userFilter != "" { 269 + rows, err = db.Query(query, userFilter, userFilter, limit, offset) 270 + } else { 271 + rows, err = db.Query(query, limit, offset) 272 + } 273 + 274 + if err != nil { 275 + return nil, 0, err 276 + } 277 + defer rows.Close() 278 + 279 + var pushes []Push 280 + for rows.Next() { 281 + var p Push 282 + if err := rows.Scan(&p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { 283 + return nil, 0, err 284 + } 285 + pushes = append(pushes, p) 286 + } 287 + 288 + // Get total count 289 + countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did" 290 + if userFilter != "" { 291 + countQuery += " WHERE u.handle = ? OR u.did = ?" 292 + } 293 + 294 + var total int 295 + if userFilter != "" { 296 + db.QueryRow(countQuery, userFilter, userFilter).Scan(&total) 297 + } else { 298 + db.QueryRow(countQuery).Scan(&total) 299 + } 300 + 301 + return pushes, total, nil 302 + } 303 + 304 + // GetUserRepositories fetches all repositories for a user 305 + func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { 306 + // Get repository summary 307 + rows, err := db.Query(` 308 + SELECT 309 + repository, 310 + COUNT(DISTINCT tag) as tag_count, 311 + COUNT(DISTINCT digest) as manifest_count, 312 + MAX(created_at) as last_push 313 + FROM ( 314 + SELECT repository, tag, digest, created_at FROM tags WHERE did = ? 315 + UNION 316 + SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ? 317 + ) 318 + GROUP BY repository 319 + ORDER BY last_push DESC 320 + `, did, did) 321 + 322 + if err != nil { 323 + return nil, err 324 + } 325 + defer rows.Close() 326 + 327 + var repos []Repository 328 + for rows.Next() { 329 + var r Repository 330 + if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &r.LastPush); err != nil { 331 + return nil, err 332 + } 333 + 334 + // Get tags for this repo 335 + tagRows, err := db.Query(` 336 + SELECT tag, digest, created_at 337 + FROM tags 338 + WHERE did = ? AND repository = ? 339 + ORDER BY created_at DESC 340 + `, did, r.Name) 341 + 342 + if err != nil { 343 + return nil, err 344 + } 345 + 346 + for tagRows.Next() { 347 + var t Tag 348 + if err := tagRows.Scan(&t.Tag, &t.Digest, &t.CreatedAt); err != nil { 349 + tagRows.Close() 350 + return nil, err 351 + } 352 + r.Tags = append(r.Tags, t) 353 + } 354 + tagRows.Close() 355 + 356 + // Get manifests for this repo 357 + manifestRows, err := db.Query(` 358 + SELECT id, digest, hold_endpoint, schema_version, media_type, 359 + config_digest, config_size, raw_manifest, created_at 360 + FROM manifests 361 + WHERE did = ? AND repository = ? 362 + ORDER BY created_at DESC 363 + `, did, r.Name) 364 + 365 + if err != nil { 366 + return nil, err 367 + } 368 + 369 + for manifestRows.Next() { 370 + var m Manifest 371 + if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 372 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt); err != nil { 373 + manifestRows.Close() 374 + return nil, err 375 + } 376 + r.Manifests = append(r.Manifests, m) 377 + } 378 + manifestRows.Close() 379 + 380 + repos = append(repos, r) 381 + } 382 + 383 + return repos, nil 384 + } 385 + ``` 386 + 387 + ## Step 2: Templates Layout 388 + 389 + ### Base Layout 390 + 391 + **pkg/appview/templates/layouts/base.html:** 392 + 393 + ```html 394 + <!DOCTYPE html> 395 + <html lang="en"> 396 + <head> 397 + <meta charset="UTF-8"> 398 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 399 + <title>{{ block "title" . }}ATCR{{ end }}</title> 400 + <link rel="stylesheet" href="/static/css/style.css"> 401 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 402 + {{ block "head" . }}{{ end }} 403 + </head> 404 + <body> 405 + {{ template "nav" . }} 406 + 407 + <main class="container"> 408 + {{ block "content" . }}{{ end }} 409 + </main> 410 + 411 + <!-- Modal container for HTMX --> 412 + <div id="modal"></div> 413 + 414 + <script src="/static/js/app.js"></script> 415 + {{ block "scripts" . }}{{ end }} 416 + </body> 417 + </html> 418 + ``` 419 + 420 + ### Navigation Component 421 + 422 + **pkg/appview/templates/components/nav.html:** 423 + 424 + ```html 425 + {{ define "nav" }} 426 + <nav class="navbar"> 427 + <div class="nav-brand"> 428 + <a href="/ui/">ATCR</a> 429 + </div> 430 + 431 + <div class="nav-search"> 432 + <form hx-get="/ui/api/recent-pushes" 433 + hx-target="#content" 434 + hx-trigger="submit" 435 + hx-include="[name='q']"> 436 + <input type="text" name="q" placeholder="Search images..." /> 437 + </form> 438 + </div> 439 + 440 + <div class="nav-links"> 441 + {{ if .User }} 442 + <a href="/ui/images">Your Images</a> 443 + <span class="user-handle">@{{ .User.Handle }}</span> 444 + <a href="/ui/settings" class="settings-icon">⚙️</a> 445 + <form action="/auth/logout" method="POST" style="display: inline;"> 446 + <button type="submit">Logout</button> 447 + </form> 448 + {{ else }} 449 + <a href="/auth/oauth/login?return_to=/ui/">Login</a> 450 + {{ end }} 451 + </div> 452 + </nav> 453 + {{ end }} 454 + ``` 455 + 456 + ## Step 3: Front Page (Homepage) 457 + 458 + **pkg/appview/templates/pages/home.html:** 459 + 460 + ```html 461 + {{ define "title" }}ATCR - Federated Container Registry{{ end }} 462 + 463 + {{ define "content" }} 464 + <div class="home-page"> 465 + <h1>Recent Pushes</h1> 466 + 467 + <div class="filters"> 468 + <button hx-get="/ui/api/recent-pushes" 469 + hx-target="#push-list" 470 + hx-swap="innerHTML">All</button> 471 + <!-- Add more filter buttons as needed --> 472 + </div> 473 + 474 + <div id="push-list" 475 + hx-get="/ui/api/recent-pushes" 476 + hx-trigger="load, every 30s" 477 + hx-swap="innerHTML"> 478 + <!-- Initial loading state --> 479 + <div class="loading">Loading recent pushes...</div> 480 + </div> 481 + </div> 482 + {{ end }} 483 + ``` 484 + 485 + **pkg/appview/templates/partials/push-list.html:** 486 + 487 + ```html 488 + {{ range .Pushes }} 489 + <div class="push-card"> 490 + <div class="push-header"> 491 + <a href="/ui/?user={{ .Handle }}" class="push-user">{{ .Handle }}</a> 492 + <span class="push-separator">/</span> 493 + <span class="push-repo">{{ .Repository }}</span> 494 + <span class="push-separator">:</span> 495 + <span class="push-tag">{{ .Tag }}</span> 496 + </div> 497 + 498 + <div class="push-details"> 499 + <code class="digest">{{ printf "%.12s" .Digest }}...</code> 500 + <span class="separator">•</span> 501 + <span class="hold">{{ .HoldEndpoint }}</span> 502 + <span class="separator">•</span> 503 + <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 504 + {{ .CreatedAt | timeAgo }} 505 + </time> 506 + </div> 507 + 508 + <div class="push-command"> 509 + <code class="pull-command">docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code> 510 + <button class="copy-btn" 511 + onclick="copyToClipboard('docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')"> 512 + 📋 Copy 513 + </button> 514 + </div> 515 + 516 + <button class="view-manifest-btn" 517 + hx-get="/ui/api/manifests/{{ .Digest }}" 518 + hx-target="#modal" 519 + hx-swap="innerHTML"> 520 + View Manifest 521 + </button> 522 + </div> 523 + {{ end }} 524 + 525 + {{ if .HasMore }} 526 + <button class="load-more" 527 + hx-get="/ui/api/recent-pushes?offset={{ .NextOffset }}" 528 + hx-target="#push-list" 529 + hx-swap="beforeend"> 530 + Load More 531 + </button> 532 + {{ end }} 533 + ``` 534 + 535 + **pkg/appview/handlers/home.go:** 536 + 537 + ```go 538 + package handlers 539 + 540 + import ( 541 + "html/template" 542 + "net/http" 543 + "strconv" 544 + "atcr.io/pkg/appview/db" 545 + ) 546 + 547 + type HomeHandler struct { 548 + DB *sql.DB 549 + Templates *template.Template 550 + } 551 + 552 + func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 553 + // Check if this is an HTMX request for the partial 554 + if r.Header.Get("HX-Request") == "true" { 555 + h.servePushList(w, r) 556 + return 557 + } 558 + 559 + // Serve full page 560 + data := struct { 561 + User *db.User 562 + }{ 563 + User: getUserFromContext(r), 564 + } 565 + 566 + h.Templates.ExecuteTemplate(w, "home.html", data) 567 + } 568 + 569 + func (h *HomeHandler) servePushList(w http.ResponseWriter, r *http.Request) { 570 + limit := 50 571 + offset := 0 572 + 573 + if o := r.URL.Query().Get("offset"); o != "" { 574 + offset, _ = strconv.Atoi(o) 575 + } 576 + 577 + userFilter := r.URL.Query().Get("user") 578 + 579 + pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter) 580 + if err != nil { 581 + http.Error(w, err.Error(), http.StatusInternalServerError) 582 + return 583 + } 584 + 585 + data := struct { 586 + Pushes []db.Push 587 + HasMore bool 588 + NextOffset int 589 + }{ 590 + Pushes: pushes, 591 + HasMore: offset+limit < total, 592 + NextOffset: offset + limit, 593 + } 594 + 595 + h.Templates.ExecuteTemplate(w, "push-list.html", data) 596 + } 597 + ``` 598 + 599 + ## Step 4: Settings Page 600 + 601 + **pkg/appview/templates/pages/settings.html:** 602 + 603 + ```html 604 + {{ define "title" }}Settings - ATCR{{ end }} 605 + 606 + {{ define "content" }} 607 + <div class="settings-page"> 608 + <h1>Settings</h1> 609 + 610 + <!-- Identity Section --> 611 + <section class="settings-section"> 612 + <h2>Identity</h2> 613 + <div class="form-group"> 614 + <label>Handle:</label> 615 + <span>{{ .Profile.Handle }}</span> 616 + </div> 617 + <div class="form-group"> 618 + <label>DID:</label> 619 + <code>{{ .Profile.DID }}</code> 620 + </div> 621 + <div class="form-group"> 622 + <label>PDS:</label> 623 + <span>{{ .Profile.PDSEndpoint }}</span> 624 + </div> 625 + </section> 626 + 627 + <!-- Default Hold Section --> 628 + <section class="settings-section"> 629 + <h2>Default Hold</h2> 630 + <p>Current: <strong>{{ .Profile.DefaultHold }}</strong></p> 631 + 632 + <form hx-post="/ui/api/profile/default-hold" 633 + hx-target="#hold-status" 634 + hx-swap="innerHTML"> 635 + 636 + <div class="form-group"> 637 + <label for="hold-select">Select from your holds:</label> 638 + <select name="hold_endpoint" id="hold-select"> 639 + {{ range .Holds }} 640 + <option value="{{ .Endpoint }}" 641 + {{ if eq .Endpoint $.Profile.DefaultHold }}selected{{ end }}> 642 + {{ .Endpoint }} {{ if .Name }}({{ .Name }}){{ end }} 643 + </option> 644 + {{ end }} 645 + <option value="">Custom URL...</option> 646 + </select> 647 + </div> 648 + 649 + <div class="form-group" id="custom-hold-group" style="display: none;"> 650 + <label for="custom-hold">Custom hold URL:</label> 651 + <input type="text" 652 + id="custom-hold" 653 + name="custom_hold" 654 + placeholder="https://hold.example.com" /> 655 + </div> 656 + 657 + <button type="submit">Save</button> 658 + </form> 659 + 660 + <div id="hold-status"></div> 661 + </section> 662 + 663 + <!-- OAuth Session Section --> 664 + <section class="settings-section"> 665 + <h2>OAuth Session</h2> 666 + <div class="form-group"> 667 + <label>Logged in as:</label> 668 + <span>{{ .Profile.Handle }}</span> 669 + </div> 670 + <div class="form-group"> 671 + <label>Session expires:</label> 672 + <time datetime="{{ .SessionExpiry.Format "2006-01-02T15:04:05Z07:00" }}"> 673 + {{ .SessionExpiry.Format "2006-01-02 15:04:05 MST" }} 674 + </time> 675 + </div> 676 + <a href="/auth/oauth/login?return_to=/ui/settings" class="btn">Re-authenticate</a> 677 + </section> 678 + </div> 679 + {{ end }} 680 + 681 + {{ define "scripts" }} 682 + <script> 683 + // Show/hide custom URL field 684 + document.getElementById('hold-select').addEventListener('change', function(e) { 685 + const customGroup = document.getElementById('custom-hold-group'); 686 + if (e.target.value === '') { 687 + customGroup.style.display = 'block'; 688 + } else { 689 + customGroup.style.display = 'none'; 690 + } 691 + }); 692 + </script> 693 + {{ end }} 694 + ``` 695 + 696 + **pkg/appview/handlers/settings.go:** 697 + 698 + ```go 699 + package handlers 700 + 701 + import ( 702 + "database/sql" 703 + "encoding/json" 704 + "html/template" 705 + "net/http" 706 + "atcr.io/pkg/atproto" 707 + ) 708 + 709 + type SettingsHandler struct { 710 + Templates *template.Template 711 + ATProtoClient *atproto.Client 712 + } 713 + 714 + func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 715 + user := getUserFromContext(r) 716 + if user == nil { 717 + http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/settings", http.StatusFound) 718 + return 719 + } 720 + 721 + // Fetch user profile from PDS 722 + profile, err := h.ATProtoClient.GetProfile(user.DID) 723 + if err != nil { 724 + http.Error(w, err.Error(), http.StatusInternalServerError) 725 + return 726 + } 727 + 728 + // Fetch user's holds 729 + holds, err := h.ATProtoClient.ListHolds(user.DID) 730 + if err != nil { 731 + http.Error(w, err.Error(), http.StatusInternalServerError) 732 + return 733 + } 734 + 735 + data := struct { 736 + Profile *atproto.SailorProfileRecord 737 + Holds []atproto.HoldRecord 738 + SessionExpiry time.Time 739 + }{ 740 + Profile: profile, 741 + Holds: holds, 742 + SessionExpiry: getSessionExpiry(r), 743 + } 744 + 745 + h.Templates.ExecuteTemplate(w, "settings.html", data) 746 + } 747 + 748 + func (h *SettingsHandler) UpdateDefaultHold(w http.ResponseWriter, r *http.Request) { 749 + user := getUserFromContext(r) 750 + if user == nil { 751 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 752 + return 753 + } 754 + 755 + holdEndpoint := r.FormValue("hold_endpoint") 756 + if holdEndpoint == "" { 757 + holdEndpoint = r.FormValue("custom_hold") 758 + } 759 + 760 + // Update profile in PDS 761 + err := h.ATProtoClient.UpdateProfile(user.DID, map[string]interface{}{ 762 + "defaultHold": holdEndpoint, 763 + }) 764 + 765 + if err != nil { 766 + w.Write([]byte(`<div class="error">Failed to update: ` + err.Error() + `</div>`)) 767 + return 768 + } 769 + 770 + w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`)) 771 + } 772 + ``` 773 + 774 + ## Step 5: Personal Images Page 775 + 776 + **pkg/appview/templates/pages/images.html:** 777 + 778 + ```html 779 + {{ define "title" }}Your Images - ATCR{{ end }} 780 + 781 + {{ define "content" }} 782 + <div class="images-page"> 783 + <h1>Your Images</h1> 784 + 785 + {{ if .Repositories }} 786 + {{ range .Repositories }} 787 + <div class="repository-card"> 788 + <div class="repo-header" 789 + hx-get="/ui/api/repositories/{{ .Name }}/toggle" 790 + hx-target="#repo-{{ .Name }}" 791 + hx-swap="outerHTML"> 792 + <h2>{{ .Name }}</h2> 793 + <div class="repo-stats"> 794 + <span>{{ .TagCount }} tags</span> 795 + <span>•</span> 796 + <span>{{ .ManifestCount }} manifests</span> 797 + <span>•</span> 798 + <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}"> 799 + Last push: {{ .LastPush | timeAgo }} 800 + </time> 801 + </div> 802 + <button class="expand-btn">▼</button> 803 + </div> 804 + 805 + <div id="repo-{{ .Name }}" class="repo-details" style="display: none;"> 806 + <!-- Tags Section --> 807 + <div class="tags-section"> 808 + <h3>Tags</h3> 809 + {{ range .Tags }} 810 + <div class="tag-row" id="tag-{{ $.Name }}-{{ .Tag }}"> 811 + <span class="tag-name">{{ .Tag }}</span> 812 + <span class="tag-arrow">→</span> 813 + <code class="tag-digest">{{ printf "%.12s" .Digest }}...</code> 814 + <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 815 + {{ .CreatedAt | timeAgo }} 816 + </time> 817 + 818 + <button class="edit-btn" 819 + hx-get="/ui/modals/edit-tag?repo={{ $.Name }}&tag={{ .Tag }}" 820 + hx-target="#modal"> 821 + ✏️ 822 + </button> 823 + 824 + <button class="delete-btn" 825 + hx-delete="/ui/api/images/{{ $.Name }}/tags/{{ .Tag }}" 826 + hx-confirm="Delete tag {{ .Tag }}?" 827 + hx-target="#tag-{{ $.Name }}-{{ .Tag }}" 828 + hx-swap="outerHTML"> 829 + 🗑️ 830 + </button> 831 + </div> 832 + {{ end }} 833 + </div> 834 + 835 + <!-- Manifests Section --> 836 + <div class="manifests-section"> 837 + <h3>Manifests</h3> 838 + {{ range .Manifests }} 839 + <div class="manifest-row" id="manifest-{{ .Digest }}"> 840 + <code class="manifest-digest">{{ printf "%.12s" .Digest }}...</code> 841 + <span>{{ .Size | humanizeBytes }}</span> 842 + <span>{{ .HoldEndpoint }}</span> 843 + <span>{{ .Architecture }}/{{ .OS }}</span> 844 + <span>{{ .LayerCount }} layers</span> 845 + 846 + <button class="view-btn" 847 + hx-get="/ui/api/manifests/{{ .Digest }}" 848 + hx-target="#modal"> 849 + View 850 + </button> 851 + 852 + {{ if not .Tagged }} 853 + <button class="delete-btn" 854 + hx-delete="/ui/api/images/{{ $.Name }}/manifests/{{ .Digest }}" 855 + hx-confirm="Delete manifest {{ printf "%.12s" .Digest }}...?" 856 + hx-target="#manifest-{{ .Digest }}" 857 + hx-swap="outerHTML"> 858 + Delete 859 + </button> 860 + {{ end }} 861 + </div> 862 + {{ end }} 863 + </div> 864 + </div> 865 + </div> 866 + {{ end }} 867 + {{ else }} 868 + <div class="empty-state"> 869 + <p>No images yet. Push your first image:</p> 870 + <code>docker push atcr.io/{{ .User.Handle }}/myapp:latest</code> 871 + </div> 872 + {{ end }} 873 + </div> 874 + {{ end }} 875 + 876 + {{ define "scripts" }} 877 + <script> 878 + // Toggle repository details 879 + document.querySelectorAll('.repo-header').forEach(header => { 880 + header.addEventListener('click', function() { 881 + const details = this.nextElementSibling; 882 + const btn = this.querySelector('.expand-btn'); 883 + 884 + if (details.style.display === 'none') { 885 + details.style.display = 'block'; 886 + btn.textContent = '▲'; 887 + } else { 888 + details.style.display = 'none'; 889 + btn.textContent = '▼'; 890 + } 891 + }); 892 + }); 893 + </script> 894 + {{ end }} 895 + ``` 896 + 897 + **pkg/appview/handlers/images.go:** 898 + 899 + ```go 900 + package handlers 901 + 902 + import ( 903 + "database/sql" 904 + "html/template" 905 + "net/http" 906 + "atcr.io/pkg/appview/db" 907 + "atcr.io/pkg/atproto" 908 + ) 909 + 910 + type ImagesHandler struct { 911 + DB *sql.DB 912 + Templates *template.Template 913 + ATProtoClient *atproto.Client 914 + } 915 + 916 + func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 917 + user := getUserFromContext(r) 918 + if user == nil { 919 + http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound) 920 + return 921 + } 922 + 923 + // Fetch repositories from PDS (user's own data) 924 + repos, err := h.ATProtoClient.ListRepositories(user.DID) 925 + if err != nil { 926 + http.Error(w, err.Error(), http.StatusInternalServerError) 927 + return 928 + } 929 + 930 + data := struct { 931 + User *db.User 932 + Repositories []db.Repository 933 + }{ 934 + User: user, 935 + Repositories: repos, 936 + } 937 + 938 + h.Templates.ExecuteTemplate(w, "images.html", data) 939 + } 940 + 941 + func (h *ImagesHandler) DeleteTag(w http.ResponseWriter, r *http.Request) { 942 + user := getUserFromContext(r) 943 + if user == nil { 944 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 945 + return 946 + } 947 + 948 + // Extract repo and tag from URL 949 + vars := mux.Vars(r) 950 + repo := vars["repository"] 951 + tag := vars["tag"] 952 + 953 + // Delete tag record from PDS 954 + err := h.ATProtoClient.DeleteTag(user.DID, repo, tag) 955 + if err != nil { 956 + http.Error(w, err.Error(), http.StatusInternalServerError) 957 + return 958 + } 959 + 960 + // Return empty response (HTMX will swap out the element) 961 + w.WriteHeader(http.StatusOK) 962 + } 963 + 964 + func (h *ImagesHandler) DeleteManifest(w http.ResponseWriter, r *http.Request) { 965 + user := getUserFromContext(r) 966 + if user == nil { 967 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 968 + return 969 + } 970 + 971 + vars := mux.Vars(r) 972 + repo := vars["repository"] 973 + digest := vars["digest"] 974 + 975 + // Check if manifest is tagged 976 + tagged, err := h.ATProtoClient.IsManifestTagged(user.DID, repo, digest) 977 + if err != nil { 978 + http.Error(w, err.Error(), http.StatusInternalServerError) 979 + return 980 + } 981 + 982 + if tagged { 983 + http.Error(w, "Cannot delete tagged manifest", http.StatusBadRequest) 984 + return 985 + } 986 + 987 + // Delete manifest from PDS 988 + err = h.ATProtoClient.DeleteManifest(user.DID, repo, digest) 989 + if err != nil { 990 + http.Error(w, err.Error(), http.StatusInternalServerError) 991 + return 992 + } 993 + 994 + w.WriteHeader(http.StatusOK) 995 + } 996 + ``` 997 + 998 + ## Step 6: Modals & Partials 999 + 1000 + **pkg/appview/templates/components/modal.html:** 1001 + 1002 + ```html 1003 + {{ define "manifest-modal" }} 1004 + <div class="modal-overlay" onclick="this.remove()"> 1005 + <div class="modal-content" onclick="event.stopPropagation()"> 1006 + <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button> 1007 + 1008 + <h2>Manifest Details</h2> 1009 + 1010 + <div class="manifest-info"> 1011 + <div class="info-row"> 1012 + <strong>Digest:</strong> 1013 + <code>{{ .Digest }}</code> 1014 + </div> 1015 + <div class="info-row"> 1016 + <strong>Media Type:</strong> 1017 + <span>{{ .MediaType }}</span> 1018 + </div> 1019 + <div class="info-row"> 1020 + <strong>Size:</strong> 1021 + <span>{{ .Size | humanizeBytes }}</span> 1022 + </div> 1023 + <div class="info-row"> 1024 + <strong>Architecture:</strong> 1025 + <span>{{ .Architecture }}/{{ .OS }}</span> 1026 + </div> 1027 + <div class="info-row"> 1028 + <strong>Created:</strong> 1029 + <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 1030 + {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }} 1031 + </time> 1032 + </div> 1033 + <div class="info-row"> 1034 + <strong>ATProto Record:</strong> 1035 + <a href="at://{{ .DID }}/io.atcr.manifest/{{ .Rkey }}" target="_blank"> 1036 + View on PDS 1037 + </a> 1038 + </div> 1039 + </div> 1040 + 1041 + <h3>Layers</h3> 1042 + <div class="layers-list"> 1043 + {{ range .Layers }} 1044 + <div class="layer-row"> 1045 + <code>{{ .Digest }}</code> 1046 + <span>{{ .Size | humanizeBytes }}</span> 1047 + <span>{{ .MediaType }}</span> 1048 + </div> 1049 + {{ end }} 1050 + </div> 1051 + 1052 + <h3>Raw Manifest</h3> 1053 + <pre class="manifest-json"><code>{{ .RawManifest }}</code></pre> 1054 + </div> 1055 + </div> 1056 + {{ end }} 1057 + ``` 1058 + 1059 + **pkg/appview/templates/partials/edit-tag-modal.html:** 1060 + 1061 + ```html 1062 + <div class="modal-overlay" onclick="this.remove()"> 1063 + <div class="modal-content" onclick="event.stopPropagation()"> 1064 + <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button> 1065 + 1066 + <h2>Edit Tag: {{ .Tag }}</h2> 1067 + 1068 + <form hx-put="/ui/api/images/{{ .Repository }}/tags/{{ .Tag }}" 1069 + hx-target="#tag-{{ .Repository }}-{{ .Tag }}" 1070 + hx-swap="outerHTML"> 1071 + 1072 + <div class="form-group"> 1073 + <label for="digest">Point to manifest:</label> 1074 + <select name="digest" id="digest" required> 1075 + {{ range .Manifests }} 1076 + <option value="{{ .Digest }}" 1077 + {{ if eq .Digest $.CurrentDigest }}selected{{ end }}> 1078 + {{ printf "%.12s" .Digest }}... ({{ .CreatedAt | timeAgo }}) 1079 + </option> 1080 + {{ end }} 1081 + </select> 1082 + </div> 1083 + 1084 + <button type="submit">Update Tag</button> 1085 + <button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button> 1086 + </form> 1087 + </div> 1088 + </div> 1089 + ``` 1090 + 1091 + ## Step 7: Authentication & Session 1092 + 1093 + **pkg/appview/session/session.go:** 1094 + 1095 + ```go 1096 + package session 1097 + 1098 + import ( 1099 + "crypto/rand" 1100 + "encoding/base64" 1101 + "net/http" 1102 + "sync" 1103 + "time" 1104 + ) 1105 + 1106 + type Session struct { 1107 + ID string 1108 + DID string 1109 + Handle string 1110 + ExpiresAt time.Time 1111 + } 1112 + 1113 + type Store struct { 1114 + mu sync.RWMutex 1115 + sessions map[string]*Session 1116 + } 1117 + 1118 + func NewStore() *Store { 1119 + return &Store{ 1120 + sessions: make(map[string]*Session), 1121 + } 1122 + } 1123 + 1124 + func (s *Store) Create(did, handle string, duration time.Duration) (*Session, error) { 1125 + s.mu.Lock() 1126 + defer s.mu.Unlock() 1127 + 1128 + // Generate random session ID 1129 + b := make([]byte, 32) 1130 + if _, err := rand.Read(b); err != nil { 1131 + return nil, err 1132 + } 1133 + 1134 + sess := &Session{ 1135 + ID: base64.URLEncoding.EncodeToString(b), 1136 + DID: did, 1137 + Handle: handle, 1138 + ExpiresAt: time.Now().Add(duration), 1139 + } 1140 + 1141 + s.sessions[sess.ID] = sess 1142 + return sess, nil 1143 + } 1144 + 1145 + func (s *Store) Get(id string) (*Session, bool) { 1146 + s.mu.RLock() 1147 + defer s.mu.RUnlock() 1148 + 1149 + sess, ok := s.sessions[id] 1150 + if !ok || time.Now().After(sess.ExpiresAt) { 1151 + return nil, false 1152 + } 1153 + 1154 + return sess, true 1155 + } 1156 + 1157 + func (s *Store) Delete(id string) { 1158 + s.mu.Lock() 1159 + defer s.mu.Unlock() 1160 + 1161 + delete(s.sessions, id) 1162 + } 1163 + 1164 + func (s *Store) Cleanup() { 1165 + s.mu.Lock() 1166 + defer s.mu.Unlock() 1167 + 1168 + now := time.Now() 1169 + for id, sess := range s.sessions { 1170 + if now.After(sess.ExpiresAt) { 1171 + delete(s.sessions, id) 1172 + } 1173 + } 1174 + } 1175 + 1176 + // SetCookie sets the session cookie 1177 + func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) { 1178 + http.SetCookie(w, &http.Cookie{ 1179 + Name: "atcr_session", 1180 + Value: sessionID, 1181 + Path: "/", 1182 + MaxAge: maxAge, 1183 + HttpOnly: true, 1184 + Secure: true, 1185 + SameSite: http.SameSiteLaxMode, 1186 + }) 1187 + } 1188 + 1189 + // GetSessionID gets session ID from cookie 1190 + func GetSessionID(r *http.Request) (string, bool) { 1191 + cookie, err := r.Cookie("atcr_session") 1192 + if err != nil { 1193 + return "", false 1194 + } 1195 + return cookie.Value, true 1196 + } 1197 + ``` 1198 + 1199 + **pkg/appview/middleware/auth.go:** 1200 + 1201 + ```go 1202 + package middleware 1203 + 1204 + import ( 1205 + "context" 1206 + "net/http" 1207 + "atcr.io/pkg/appview/session" 1208 + "atcr.io/pkg/appview/db" 1209 + ) 1210 + 1211 + type contextKey string 1212 + 1213 + const userKey contextKey = "user" 1214 + 1215 + func RequireAuth(store *session.Store) func(http.Handler) http.Handler { 1216 + return func(next http.Handler) http.Handler { 1217 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1218 + sessionID, ok := session.GetSessionID(r) 1219 + if !ok { 1220 + http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 1221 + return 1222 + } 1223 + 1224 + sess, ok := store.Get(sessionID) 1225 + if !ok { 1226 + http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 1227 + return 1228 + } 1229 + 1230 + user := &db.User{ 1231 + DID: sess.DID, 1232 + Handle: sess.Handle, 1233 + } 1234 + 1235 + ctx := context.WithValue(r.Context(), userKey, user) 1236 + next.ServeHTTP(w, r.WithContext(ctx)) 1237 + }) 1238 + } 1239 + } 1240 + 1241 + func GetUser(r *http.Request) *db.User { 1242 + user, ok := r.Context().Value(userKey).(*db.User) 1243 + if !ok { 1244 + return nil 1245 + } 1246 + return user 1247 + } 1248 + ``` 1249 + 1250 + ## Step 8: Main Integration 1251 + 1252 + **cmd/registry/main.go (additions):** 1253 + 1254 + ```go 1255 + package main 1256 + 1257 + import ( 1258 + "log" 1259 + "net/http" 1260 + "time" 1261 + 1262 + "github.com/gorilla/mux" 1263 + "atcr.io/pkg/appview" 1264 + "atcr.io/pkg/appview/handlers" 1265 + "atcr.io/pkg/appview/db" 1266 + "atcr.io/pkg/appview/session" 1267 + "atcr.io/pkg/appview/middleware" 1268 + ) 1269 + 1270 + func main() { 1271 + // Initialize database 1272 + database, err := db.InitDB("/var/lib/atcr/ui.db") 1273 + if err != nil { 1274 + log.Fatal(err) 1275 + } 1276 + 1277 + // Initialize session store 1278 + sessionStore := session.NewStore() 1279 + 1280 + // Start cleanup goroutine 1281 + go func() { 1282 + for { 1283 + time.Sleep(5 * time.Minute) 1284 + sessionStore.Cleanup() 1285 + } 1286 + }() 1287 + 1288 + // Load embedded templates 1289 + tmpl, err := appview.Templates() 1290 + if err != nil { 1291 + log.Fatal(err) 1292 + } 1293 + 1294 + // Setup router 1295 + r := mux.NewRouter() 1296 + 1297 + // Static files (embedded) 1298 + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", appview.StaticHandler())) 1299 + 1300 + // UI routes (public) 1301 + r.Handle("/ui/", &handlers.HomeHandler{ 1302 + DB: database, 1303 + Templates: tmpl, 1304 + }) 1305 + 1306 + // UI routes (authenticated) 1307 + authRouter := r.PathPrefix("/ui").Subrouter() 1308 + authRouter.Use(middleware.RequireAuth(sessionStore)) 1309 + 1310 + authRouter.Handle("/images", &handlers.ImagesHandler{ 1311 + DB: database, 1312 + Templates: tmpl, 1313 + }) 1314 + 1315 + authRouter.Handle("/settings", &handlers.SettingsHandler{ 1316 + Templates: tmpl, 1317 + }) 1318 + 1319 + // API routes 1320 + authRouter.HandleFunc("/api/images/{repository}/tags/{tag}", 1321 + handlers.DeleteTag).Methods("DELETE") 1322 + authRouter.HandleFunc("/api/images/{repository}/manifests/{digest}", 1323 + handlers.DeleteManifest).Methods("DELETE") 1324 + 1325 + // ... rest of your existing routes 1326 + 1327 + log.Println("Server starting on :5000") 1328 + http.ListenAndServe(":5000", r) 1329 + } 1330 + ``` 1331 + 1332 + ## Step 9: Styling (Basic CSS) 1333 + 1334 + **pkg/appview/static/css/style.css:** 1335 + 1336 + ```css 1337 + :root { 1338 + --primary: #0066cc; 1339 + --bg: #ffffff; 1340 + --fg: #1a1a1a; 1341 + --border: #e0e0e0; 1342 + --code-bg: #f5f5f5; 1343 + } 1344 + 1345 + * { 1346 + margin: 0; 1347 + padding: 0; 1348 + box-sizing: border-box; 1349 + } 1350 + 1351 + body { 1352 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 1353 + background: var(--bg); 1354 + color: var(--fg); 1355 + line-height: 1.6; 1356 + } 1357 + 1358 + .container { 1359 + max-width: 1200px; 1360 + margin: 0 auto; 1361 + padding: 20px; 1362 + } 1363 + 1364 + /* Navigation */ 1365 + .navbar { 1366 + background: var(--fg); 1367 + color: white; 1368 + padding: 1rem 2rem; 1369 + display: flex; 1370 + justify-content: space-between; 1371 + align-items: center; 1372 + } 1373 + 1374 + .nav-brand a { 1375 + color: white; 1376 + text-decoration: none; 1377 + font-size: 1.5rem; 1378 + font-weight: bold; 1379 + } 1380 + 1381 + .nav-links { 1382 + display: flex; 1383 + gap: 1rem; 1384 + align-items: center; 1385 + } 1386 + 1387 + .nav-links a { 1388 + color: white; 1389 + text-decoration: none; 1390 + } 1391 + 1392 + /* Push Cards */ 1393 + .push-card { 1394 + border: 1px solid var(--border); 1395 + border-radius: 8px; 1396 + padding: 1rem; 1397 + margin-bottom: 1rem; 1398 + background: white; 1399 + } 1400 + 1401 + .push-header { 1402 + font-size: 1.1rem; 1403 + margin-bottom: 0.5rem; 1404 + } 1405 + 1406 + .push-user { 1407 + color: var(--primary); 1408 + text-decoration: none; 1409 + } 1410 + 1411 + .push-command { 1412 + display: flex; 1413 + gap: 0.5rem; 1414 + align-items: center; 1415 + margin-top: 0.5rem; 1416 + padding: 0.5rem; 1417 + background: var(--code-bg); 1418 + border-radius: 4px; 1419 + } 1420 + 1421 + .pull-command { 1422 + flex: 1; 1423 + font-family: 'Monaco', 'Courier New', monospace; 1424 + font-size: 0.9rem; 1425 + } 1426 + 1427 + .copy-btn { 1428 + padding: 0.25rem 0.5rem; 1429 + background: var(--primary); 1430 + color: white; 1431 + border: none; 1432 + border-radius: 4px; 1433 + cursor: pointer; 1434 + } 1435 + 1436 + /* Repository Cards */ 1437 + .repository-card { 1438 + border: 1px solid var(--border); 1439 + border-radius: 8px; 1440 + margin-bottom: 1rem; 1441 + background: white; 1442 + } 1443 + 1444 + .repo-header { 1445 + padding: 1rem; 1446 + cursor: pointer; 1447 + display: flex; 1448 + justify-content: space-between; 1449 + align-items: center; 1450 + background: #f9f9f9; 1451 + border-radius: 8px 8px 0 0; 1452 + } 1453 + 1454 + .repo-header:hover { 1455 + background: #f0f0f0; 1456 + } 1457 + 1458 + .repo-details { 1459 + padding: 1rem; 1460 + } 1461 + 1462 + .tag-row, .manifest-row { 1463 + display: flex; 1464 + gap: 1rem; 1465 + align-items: center; 1466 + padding: 0.5rem; 1467 + border-bottom: 1px solid var(--border); 1468 + } 1469 + 1470 + .tag-row:last-child, .manifest-row:last-child { 1471 + border-bottom: none; 1472 + } 1473 + 1474 + /* Modal */ 1475 + .modal-overlay { 1476 + position: fixed; 1477 + top: 0; 1478 + left: 0; 1479 + right: 0; 1480 + bottom: 0; 1481 + background: rgba(0, 0, 0, 0.5); 1482 + display: flex; 1483 + justify-content: center; 1484 + align-items: center; 1485 + z-index: 1000; 1486 + } 1487 + 1488 + .modal-content { 1489 + background: white; 1490 + padding: 2rem; 1491 + border-radius: 8px; 1492 + max-width: 800px; 1493 + max-height: 80vh; 1494 + overflow-y: auto; 1495 + position: relative; 1496 + } 1497 + 1498 + .modal-close { 1499 + position: absolute; 1500 + top: 1rem; 1501 + right: 1rem; 1502 + background: none; 1503 + border: none; 1504 + font-size: 1.5rem; 1505 + cursor: pointer; 1506 + } 1507 + 1508 + .manifest-json { 1509 + background: var(--code-bg); 1510 + padding: 1rem; 1511 + border-radius: 4px; 1512 + overflow-x: auto; 1513 + font-family: 'Monaco', 'Courier New', monospace; 1514 + font-size: 0.85rem; 1515 + } 1516 + 1517 + /* Buttons */ 1518 + button, .btn { 1519 + padding: 0.5rem 1rem; 1520 + background: var(--primary); 1521 + color: white; 1522 + border: none; 1523 + border-radius: 4px; 1524 + cursor: pointer; 1525 + text-decoration: none; 1526 + display: inline-block; 1527 + } 1528 + 1529 + button:hover, .btn:hover { 1530 + opacity: 0.9; 1531 + } 1532 + 1533 + .delete-btn { 1534 + background: #dc3545; 1535 + } 1536 + 1537 + /* Loading state */ 1538 + .loading { 1539 + text-align: center; 1540 + padding: 2rem; 1541 + color: #666; 1542 + } 1543 + 1544 + /* Forms */ 1545 + .form-group { 1546 + margin-bottom: 1rem; 1547 + } 1548 + 1549 + .form-group label { 1550 + display: block; 1551 + margin-bottom: 0.5rem; 1552 + font-weight: 500; 1553 + } 1554 + 1555 + .form-group input, 1556 + .form-group select { 1557 + width: 100%; 1558 + padding: 0.5rem; 1559 + border: 1px solid var(--border); 1560 + border-radius: 4px; 1561 + font-size: 1rem; 1562 + } 1563 + ``` 1564 + 1565 + ## Step 10: Helper Functions 1566 + 1567 + **pkg/appview/static/js/app.js:** 1568 + 1569 + ```javascript 1570 + // Copy to clipboard 1571 + function copyToClipboard(text) { 1572 + navigator.clipboard.writeText(text).then(() => { 1573 + // Show success feedback 1574 + const btn = event.target; 1575 + const originalText = btn.textContent; 1576 + btn.textContent = '✓ Copied!'; 1577 + setTimeout(() => { 1578 + btn.textContent = originalText; 1579 + }, 2000); 1580 + }); 1581 + } 1582 + 1583 + // Time ago helper (for client-side rendering) 1584 + function timeAgo(date) { 1585 + const seconds = Math.floor((new Date() - new Date(date)) / 1000); 1586 + 1587 + const intervals = { 1588 + year: 31536000, 1589 + month: 2592000, 1590 + week: 604800, 1591 + day: 86400, 1592 + hour: 3600, 1593 + minute: 60, 1594 + second: 1 1595 + }; 1596 + 1597 + for (const [name, secondsInInterval] of Object.entries(intervals)) { 1598 + const interval = Math.floor(seconds / secondsInInterval); 1599 + if (interval >= 1) { 1600 + return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`; 1601 + } 1602 + } 1603 + 1604 + return 'just now'; 1605 + } 1606 + 1607 + // Update timestamps on page load 1608 + document.addEventListener('DOMContentLoaded', () => { 1609 + document.querySelectorAll('time[datetime]').forEach(el => { 1610 + const date = el.getAttribute('datetime'); 1611 + el.textContent = timeAgo(date); 1612 + }); 1613 + }); 1614 + ``` 1615 + 1616 + **Template helper functions (in Go):** 1617 + 1618 + ```go 1619 + // Add to your template loading 1620 + funcMap := template.FuncMap{ 1621 + "timeAgo": func(t time.Time) string { 1622 + duration := time.Since(t) 1623 + 1624 + if duration < time.Minute { 1625 + return "just now" 1626 + } else if duration < time.Hour { 1627 + mins := int(duration.Minutes()) 1628 + if mins == 1 { 1629 + return "1 minute ago" 1630 + } 1631 + return fmt.Sprintf("%d minutes ago", mins) 1632 + } else if duration < 24*time.Hour { 1633 + hours := int(duration.Hours()) 1634 + if hours == 1 { 1635 + return "1 hour ago" 1636 + } 1637 + return fmt.Sprintf("%d hours ago", hours) 1638 + } else { 1639 + days := int(duration.Hours() / 24) 1640 + if days == 1 { 1641 + return "1 day ago" 1642 + } 1643 + return fmt.Sprintf("%d days ago", days) 1644 + } 1645 + }, 1646 + 1647 + "humanizeBytes": func(bytes int64) string { 1648 + const unit = 1024 1649 + if bytes < unit { 1650 + return fmt.Sprintf("%d B", bytes) 1651 + } 1652 + div, exp := int64(unit), 0 1653 + for n := bytes / unit; n >= unit; n /= unit { 1654 + div *= unit 1655 + exp++ 1656 + } 1657 + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 1658 + }, 1659 + } 1660 + 1661 + tmpl := template.New("").Funcs(funcMap) 1662 + tmpl = template.Must(tmpl.ParseGlob("web/templates/**/*.html")) 1663 + ``` 1664 + 1665 + ## Implementation Checklist 1666 + 1667 + ### Phase 1: Foundation 1668 + - [ ] Set up project structure 1669 + - [ ] Initialize SQLite database with schema 1670 + - [ ] Create data models and query functions 1671 + - [ ] Write database tests 1672 + 1673 + ### Phase 2: Templates 1674 + - [ ] Create base layout template 1675 + - [ ] Create navigation component 1676 + - [ ] Create home page template 1677 + - [ ] Create settings page template 1678 + - [ ] Create images page template 1679 + - [ ] Create modal templates 1680 + 1681 + ### Phase 3: Handlers 1682 + - [ ] Implement home handler (firehose display) 1683 + - [ ] Implement settings handler (profile + holds) 1684 + - [ ] Implement images handler (repository list) 1685 + - [ ] Implement API endpoints (delete tag, delete manifest) 1686 + - [ ] Add HTMX partial responses 1687 + 1688 + ### Phase 4: Authentication 1689 + - [ ] Implement session store 1690 + - [ ] Create auth middleware 1691 + - [ ] Wire up OAuth login (reuse existing) 1692 + - [ ] Add logout functionality 1693 + - [ ] Test auth flow 1694 + 1695 + ### Phase 5: Firehose Worker 1696 + - [ ] Implement Jetstream client 1697 + - [ ] Create firehose worker 1698 + - [ ] Add event handlers (manifest, tag) 1699 + - [ ] Test with real firehose 1700 + - [ ] Add cursor persistence 1701 + 1702 + ### Phase 6: Polish 1703 + - [ ] Add CSS styling 1704 + - [ ] Implement copy-to-clipboard 1705 + - [ ] Add loading states 1706 + - [ ] Error handling and user feedback 1707 + - [ ] Responsive design 1708 + - [ ] CSRF protection 1709 + 1710 + ### Phase 7: Testing 1711 + - [ ] Unit tests for handlers 1712 + - [ ] Database query tests 1713 + - [ ] Integration tests (full flow) 1714 + - [ ] Manual testing with real data 1715 + 1716 + ## Performance Optimizations 1717 + 1718 + ### HTMX Optimizations 1719 + 1. **Prefetching:** Add `hx-trigger="mouseenter"` to links for hover prefetch 1720 + 2. **Caching:** Use `hx-cache="true"` for cacheable content 1721 + 3. **Optimistic updates:** Remove elements immediately, rollback on error 1722 + 4. **Debouncing:** Add `delay:500ms` to search inputs 1723 + 1724 + ### Database Optimizations 1725 + 1. **Indexes:** Already defined in schema (did, repo, created_at, digest) 1726 + 2. **Connection pooling:** Use `db.SetMaxOpenConns(25)` 1727 + 3. **Prepared statements:** Cache frequently used queries 1728 + 4. **Batch inserts:** For firehose events, batch into transactions 1729 + 1730 + ### Template Optimizations 1731 + 1. **Pre-parse:** Parse templates once at startup, not per request 1732 + 2. **Caching:** Cache rendered partials for static content 1733 + 3. **Minification:** Minify HTML/CSS/JS in production 1734 + 1735 + ## Security Checklist 1736 + 1737 + - [ ] Session cookies: Secure, HttpOnly, SameSite=Lax 1738 + - [ ] CSRF tokens for mutations (POST/DELETE) 1739 + - [ ] Input validation (sanitize search, filters) 1740 + - [ ] Rate limiting on API endpoints 1741 + - [ ] SQL injection protection (parameterized queries) 1742 + - [ ] Authorization checks (user owns resource) 1743 + - [ ] XSS protection (escape template output) 1744 + 1745 + ## Deployment 1746 + 1747 + ### Development 1748 + ```bash 1749 + # Run migrations 1750 + go run cmd/registry/main.go migrate 1751 + 1752 + # Start server 1753 + go run cmd/registry/main.go serve 1754 + ``` 1755 + 1756 + ### Production 1757 + ```bash 1758 + # Build binary 1759 + go build -o bin/atcr-registry ./cmd/registry 1760 + 1761 + # Run with config 1762 + ./bin/atcr-registry serve config/production.yml 1763 + ``` 1764 + 1765 + ### Environment Variables 1766 + ```bash 1767 + UI_ENABLED=true 1768 + UI_DATABASE_PATH=/var/lib/atcr/ui.db 1769 + UI_FIREHOSE_ENDPOINT=wss://jetstream.atproto.tools/subscribe 1770 + UI_SESSION_DURATION=24h 1771 + ``` 1772 + 1773 + ## Next Steps After V1 1774 + 1775 + 1. **Add search:** Implement full-text search on SQLite 1776 + 2. **Public profiles:** `/ui/@alice` shows public view 1777 + 3. **Manifest diff:** Compare manifest versions 1778 + 4. **Export data:** Download all your images as JSON 1779 + 5. **Webhook notifications:** Alert on new pushes 1780 + 6. **CLI integration:** `atcr ui open` to launch browser 1781 + 1782 + --- 1783 + 1784 + ## Key Benefits of This Approach 1785 + 1786 + ### Single Binary Deployment 1787 + - All templates and static files embedded with `//go:embed` 1788 + - No need to ship separate `web/` directory 1789 + - Single `atcr-registry` binary contains everything 1790 + - Easy deployment: just copy one file 1791 + 1792 + ### Package Structure 1793 + - `pkg/appview` makes sense semantically (it's the AppView, not just UI) 1794 + - Contains both backend (db, firehose) and frontend (templates, handlers) 1795 + - Clear separation from core OCI registry logic 1796 + - Easy to test and develop independently 1797 + 1798 + ### Embedded Assets 1799 + ```go 1800 + // pkg/appview/appview.go 1801 + //go:embed templates/*.html templates/**/*.html 1802 + var templatesFS embed.FS 1803 + 1804 + //go:embed static/* 1805 + var staticFS embed.FS 1806 + ``` 1807 + 1808 + **Build:** 1809 + ```bash 1810 + go build -o bin/atcr-registry ./cmd/registry 1811 + ``` 1812 + 1813 + **Deploy:** 1814 + ```bash 1815 + scp bin/atcr-registry server:/usr/local/bin/ 1816 + # Done! No webpack, no node_modules, no separate assets folder 1817 + ``` 1818 + 1819 + ### Development Workflow 1820 + 1. Edit templates in `pkg/appview/templates/` 1821 + 2. Edit CSS/JS in `pkg/appview/static/` 1822 + 3. Run `go build` - assets auto-embedded 1823 + 4. No build tools, no npm, just Go 1824 + 1825 + --- 1826 + 1827 + This guide provides a complete implementation path for ATCR AppView UI using html/template + HTMX with embedded assets. Start with Phase 1 (embed setup + database) and work your way through each phase sequentially.
+631
docs/APPVIEW-UI-V1.md
··· 1 + # ATCR AppView UI - Version 1 Specification 2 + 3 + ## Overview 4 + 5 + The ATCR AppView UI provides a web interface for discovering, managing, and configuring container images in the ATCR registry. Version 1 focuses on three core pages that leverage existing functionality: 6 + 7 + 1. **Front Page** - Federated image discovery via firehose 8 + 2. **Settings Page** - Profile and hold configuration 9 + 3. **Personal Page** - Manage your images and tags 10 + 11 + ## Architecture 12 + 13 + ### Tech Stack 14 + 15 + - **Backend:** Go (existing AppView codebase) 16 + - **Frontend:** TBD (Go templates/Templ or separate SPA) 17 + - **Database:** SQLite (firehose data cache) 18 + - **Styling:** TBD (plain CSS, Tailwind, etc.) 19 + - **Authentication:** OAuth with DPoP (reuse existing implementation) 20 + 21 + ### Components 22 + 23 + ``` 24 + ┌─────────────────────────────────────────────────────────────┐ 25 + │ Web UI (Browser) │ 26 + └─────────────────────────────────────────────────────────────┘ 27 + 28 + 29 + ┌─────────────────────────────────────────────────────────────┐ 30 + │ AppView HTTP Server │ 31 + │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 32 + │ │ UI Endpoints │ │ OCI API │ │ OAuth Server │ │ 33 + │ │ /ui/* │ │ /v2/* │ │ /auth/* │ │ 34 + │ └──────────────┘ └──────────────┘ └──────────────┘ │ 35 + └─────────────────────────────────────────────────────────────┘ 36 + 37 + ┌─────────┴─────────┐ 38 + ▼ ▼ 39 + ┌──────────────────┐ ┌──────────────────┐ 40 + │ SQLite Database │ │ ATProto Client │ 41 + │ (Firehose cache) │ │ (PDS operations) │ 42 + └──────────────────┘ └──────────────────┘ 43 + 44 + ┌──────────────────┐ │ 45 + │ Firehose Worker │───────────┘ 46 + │ (Background) │ 47 + └──────────────────┘ 48 + 49 + 50 + ┌──────────────────┐ 51 + │ ATProto Firehose │ 52 + │ (Jetstream/Relay)│ 53 + └──────────────────┘ 54 + ``` 55 + 56 + ## Database Schema 57 + 58 + SQLite database for caching firehose data and enabling fast queries. 59 + 60 + ### Tables 61 + 62 + **users** 63 + ```sql 64 + CREATE TABLE users ( 65 + did TEXT PRIMARY KEY, 66 + handle TEXT NOT NULL, 67 + pds_endpoint TEXT NOT NULL, 68 + last_seen TIMESTAMP NOT NULL, 69 + UNIQUE(handle) 70 + ); 71 + CREATE INDEX idx_users_handle ON users(handle); 72 + ``` 73 + 74 + **manifests** 75 + ```sql 76 + CREATE TABLE manifests ( 77 + id INTEGER PRIMARY KEY AUTOINCREMENT, 78 + did TEXT NOT NULL, 79 + repository TEXT NOT NULL, 80 + digest TEXT NOT NULL, 81 + hold_endpoint TEXT NOT NULL, 82 + schema_version INTEGER NOT NULL, 83 + media_type TEXT NOT NULL, 84 + config_digest TEXT, 85 + config_size INTEGER, 86 + raw_manifest TEXT NOT NULL, -- JSON blob 87 + created_at TIMESTAMP NOT NULL, 88 + UNIQUE(did, repository, digest), 89 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 90 + ); 91 + CREATE INDEX idx_manifests_did_repo ON manifests(did, repository); 92 + CREATE INDEX idx_manifests_created_at ON manifests(created_at DESC); 93 + CREATE INDEX idx_manifests_digest ON manifests(digest); 94 + ``` 95 + 96 + **layers** 97 + ```sql 98 + CREATE TABLE layers ( 99 + manifest_id INTEGER NOT NULL, 100 + digest TEXT NOT NULL, 101 + size INTEGER NOT NULL, 102 + media_type TEXT NOT NULL, 103 + layer_index INTEGER NOT NULL, 104 + PRIMARY KEY(manifest_id, layer_index), 105 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 106 + ); 107 + CREATE INDEX idx_layers_digest ON layers(digest); 108 + ``` 109 + 110 + **tags** 111 + ```sql 112 + CREATE TABLE tags ( 113 + id INTEGER PRIMARY KEY AUTOINCREMENT, 114 + did TEXT NOT NULL, 115 + repository TEXT NOT NULL, 116 + tag TEXT NOT NULL, 117 + digest TEXT NOT NULL, 118 + created_at TIMESTAMP NOT NULL, 119 + UNIQUE(did, repository, tag), 120 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 121 + ); 122 + CREATE INDEX idx_tags_did_repo ON tags(did, repository); 123 + ``` 124 + 125 + **firehose_cursor** 126 + ```sql 127 + CREATE TABLE firehose_cursor ( 128 + id INTEGER PRIMARY KEY CHECK (id = 1), 129 + cursor INTEGER NOT NULL, 130 + updated_at TIMESTAMP NOT NULL 131 + ); 132 + ``` 133 + 134 + ## Firehose Worker 135 + 136 + Background goroutine that subscribes to ATProto firehose and populates the database. 137 + 138 + ### Implementation 139 + 140 + ```go 141 + // pkg/ui/firehose/worker.go 142 + 143 + type Worker struct { 144 + db *sql.DB 145 + jetstream *JetstreamClient 146 + resolver *atproto.Resolver 147 + stopCh chan struct{} 148 + } 149 + 150 + func (w *Worker) Start() error { 151 + // Load cursor from database 152 + cursor := w.loadCursor() 153 + 154 + // Subscribe to firehose 155 + events := w.jetstream.Subscribe(cursor, []string{ 156 + "io.atcr.manifest", 157 + "io.atcr.tag", 158 + }) 159 + 160 + for { 161 + select { 162 + case event := <-events: 163 + w.handleEvent(event) 164 + case <-w.stopCh: 165 + return nil 166 + } 167 + } 168 + } 169 + 170 + func (w *Worker) handleEvent(event FirehoseEvent) error { 171 + switch event.Collection { 172 + case "io.atcr.manifest": 173 + return w.handleManifest(event) 174 + case "io.atcr.tag": 175 + return w.handleTag(event) 176 + } 177 + return nil 178 + } 179 + ``` 180 + 181 + ### Event Handling 182 + 183 + **Manifest create:** 184 + - Resolve DID → handle, PDS endpoint 185 + - Insert/update user record 186 + - Parse manifest JSON 187 + - Insert manifest record 188 + - Insert layer records 189 + 190 + **Tag create/update:** 191 + - Insert/update tag record 192 + - Link to existing manifest 193 + 194 + **Record deletion:** 195 + - Delete from database (cascade handles related records) 196 + 197 + ### Firehose Connection 198 + 199 + Use Jetstream (bluesky-social/jetstream) or connect directly to relay: 200 + - **Jetstream:** Websocket to `wss://jetstream.atproto.tools/subscribe` 201 + - **Relay:** Websocket to relay (e.g., `wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos`) 202 + 203 + Jetstream is simpler and filters events server-side. 204 + 205 + ## Page Specifications 206 + 207 + ### 1. Front Page - Federated Discovery 208 + 209 + **URL:** `/ui/` or `/ui/explore` 210 + 211 + **Purpose:** Discover recently pushed images across all ATCR users. 212 + 213 + **Layout:** 214 + ``` 215 + ┌─────────────────────────────────────────────────────────────┐ 216 + │ ATCR [Search] [@handle] [Login] │ 217 + ├─────────────────────────────────────────────────────────────┤ 218 + │ Recent Pushes [Filter ▼]│ 219 + │ │ 220 + │ ┌───────────────────────────────────────────────────────┐ │ 221 + │ │ alice.bsky.social/nginx:latest │ │ 222 + │ │ sha256:abc123... • hold1.alice.com • 2 hours ago │ │ 223 + │ │ [docker pull atcr.io/alice.bsky.social/nginx:latest] │ │ 224 + │ └───────────────────────────────────────────────────────┘ │ 225 + │ │ 226 + │ ┌───────────────────────────────────────────────────────┐ │ 227 + │ │ bob.dev/myapp:v1.2.3 │ │ 228 + │ │ sha256:def456... • atcr-storage.fly.dev • 5 hours ago │ │ 229 + │ │ [docker pull atcr.io/bob.dev/myapp:v1.2.3] │ │ 230 + │ └───────────────────────────────────────────────────────┘ │ 231 + │ │ 232 + │ [Load more...] │ 233 + └─────────────────────────────────────────────────────────────┘ 234 + ``` 235 + 236 + **Features:** 237 + - List of recent pushes (manifests + tags) 238 + - Show: handle, repository, tag, digest (truncated), timestamp, hold endpoint 239 + - Copy-paste pull command with click-to-copy 240 + - Filter by user (click handle to filter) 241 + - Search by repository name or tag 242 + - Click manifest to view details (modal or dedicated page) 243 + - Pagination (50 items per page) 244 + 245 + **API Endpoint:** 246 + ``` 247 + GET /ui/api/recent-pushes 248 + Query params: 249 + - limit (default: 50) 250 + - offset (default: 0) 251 + - user (optional: filter by DID or handle) 252 + - repository (optional: filter by repo name) 253 + 254 + Response: 255 + { 256 + "pushes": [ 257 + { 258 + "did": "did:plc:alice123", 259 + "handle": "alice.bsky.social", 260 + "repository": "nginx", 261 + "tag": "latest", 262 + "digest": "sha256:abc123...", 263 + "hold_endpoint": "https://hold1.alice.com", 264 + "created_at": "2025-10-05T12:34:56Z", 265 + "pull_command": "docker pull atcr.io/alice.bsky.social/nginx:latest" 266 + } 267 + ], 268 + "total": 1234, 269 + "offset": 0, 270 + "limit": 50 271 + } 272 + ``` 273 + 274 + **Manifest Details Modal:** 275 + - Full manifest JSON (syntax highlighted) 276 + - Layer list with digests and sizes 277 + - Link to ATProto record (at://did/io.atcr.manifest/rkey) 278 + - Architecture, OS, labels 279 + - Creation timestamp 280 + 281 + ### 2. Settings Page 282 + 283 + **URL:** `/ui/settings` 284 + 285 + **Auth:** Requires login (OAuth) 286 + 287 + **Purpose:** Configure profile and hold preferences. 288 + 289 + **Layout:** 290 + ``` 291 + ┌─────────────────────────────────────────────────────────────┐ 292 + │ ATCR [@alice] [⚙️] │ 293 + ├─────────────────────────────────────────────────────────────┤ 294 + │ Settings │ 295 + │ │ 296 + │ ┌─ Identity ───────────────────────────────────────────┐ │ 297 + │ │ Handle: alice.bsky.social │ │ 298 + │ │ DID: did:plc:alice123abc (read-only) │ │ 299 + │ │ PDS: https://bsky.social (read-only) │ │ 300 + │ └───────────────────────────────────────────────────────┘ │ 301 + │ │ 302 + │ ┌─ Default Hold ──────────────────────────────────────┐ │ 303 + │ │ Current: https://hold1.alice.com │ │ 304 + │ │ │ │ 305 + │ │ [Dropdown: Select from your holds ▼] │ │ 306 + │ │ • https://hold1.alice.com (Your BYOS) │ │ 307 + │ │ • https://storage.atcr.io (AppView default) │ │ 308 + │ │ • [Custom URL...] │ │ 309 + │ │ │ │ 310 + │ │ Custom hold URL: [_____________________] │ │ 311 + │ │ │ │ 312 + │ │ [Save] │ │ 313 + │ └───────────────────────────────────────────────────────┘ │ 314 + │ │ 315 + │ ┌─ OAuth Session ─────────────────────────────────────┐ │ 316 + │ │ Logged in as: alice.bsky.social │ │ 317 + │ │ Session expires: 2025-10-06 14:23:00 UTC │ │ 318 + │ │ [Re-authenticate] │ │ 319 + │ └───────────────────────────────────────────────────────┘ │ 320 + └─────────────────────────────────────────────────────────────┘ 321 + ``` 322 + 323 + **Features:** 324 + - Display current identity (handle, DID, PDS) 325 + - Default hold configuration: 326 + - Dropdown showing user's `io.atcr.hold` records (query from PDS) 327 + - Option to select AppView's default storage endpoint 328 + - Manual entry for custom hold URL 329 + - "Save" button updates `io.atcr.sailor.profile.defaultHold` 330 + - OAuth session status 331 + - Re-authenticate button (redirects to OAuth flow) 332 + 333 + **API Endpoints:** 334 + 335 + ``` 336 + GET /ui/api/profile 337 + Auth: Required (session cookie) 338 + Response: 339 + { 340 + "did": "did:plc:alice123", 341 + "handle": "alice.bsky.social", 342 + "pds_endpoint": "https://bsky.social", 343 + "default_hold": "https://hold1.alice.com", 344 + "holds": [ 345 + { 346 + "endpoint": "https://hold1.alice.com", 347 + "name": "My BYOS Storage", 348 + "public": false 349 + } 350 + ], 351 + "session_expires_at": "2025-10-06T14:23:00Z" 352 + } 353 + 354 + POST /ui/api/profile/default-hold 355 + Auth: Required 356 + Body: 357 + { 358 + "hold_endpoint": "https://hold1.alice.com" 359 + } 360 + Response: 361 + { 362 + "success": true 363 + } 364 + ``` 365 + 366 + ### 3. Personal Page - Your Images 367 + 368 + **URL:** `/ui/images` or `/ui/@{handle}` 369 + 370 + **Auth:** Requires login (OAuth) 371 + 372 + **Purpose:** Manage your container images and tags. 373 + 374 + **Layout:** 375 + ``` 376 + ┌─────────────────────────────────────────────────────────────┐ 377 + │ ATCR [@alice] [⚙️] │ 378 + ├─────────────────────────────────────────────────────────────┤ 379 + │ Your Images │ 380 + │ │ 381 + │ ┌─ nginx ──────────────────────────────────────────────┐ │ 382 + │ │ 3 tags • 5 manifests • Last push: 2 hours ago │ │ 383 + │ │ │ │ 384 + │ │ Tags: │ │ 385 + │ │ ┌────────────────────────────────────────────────┐ │ │ 386 + │ │ │ latest → sha256:abc123... (2 hours ago) [✏️][🗑️]│ │ │ 387 + │ │ │ v1.25 → sha256:def456... (1 day ago) [✏️][🗑️]│ │ │ 388 + │ │ │ alpine → sha256:ghi789... (3 days ago) [✏️][🗑️]│ │ │ 389 + │ │ └────────────────────────────────────────────────┘ │ │ 390 + │ │ │ │ 391 + │ │ Manifests: │ │ 392 + │ │ ┌────────────────────────────────────────────────┐ │ │ 393 + │ │ │ sha256:abc123... • 45MB • hold1.alice.com │ │ │ 394 + │ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │ 395 + │ │ │ sha256:def456... • 42MB • hold1.alice.com │ │ │ 396 + │ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │ 397 + │ │ └────────────────────────────────────────────────┘ │ │ 398 + │ └───────────────────────────────────────────────────────┘ │ 399 + │ │ 400 + │ ┌─ myapp ──────────────────────────────────────────────┐ │ 401 + │ │ 2 tags • 2 manifests • Last push: 1 day ago │ │ 402 + │ │ [Expand ▼] │ │ 403 + │ └───────────────────────────────────────────────────────┘ │ 404 + └─────────────────────────────────────────────────────────────┘ 405 + ``` 406 + 407 + **Features:** 408 + 409 + **Repository List:** 410 + - Group manifests by repository name 411 + - Show: tag count, manifest count, last push time 412 + - Collapsible/expandable repository cards 413 + 414 + **Repository Details (Expanded):** 415 + - **Tags:** Table showing tag → manifest digest → timestamp 416 + - Edit tag: Modal to re-point tag to different manifest digest 417 + - Delete tag: Confirm dialog, removes `io.atcr.tag` record from PDS 418 + - **Manifests:** List of all manifests in repository 419 + - Show: digest (truncated), size, hold endpoint, architecture, layer count 420 + - View: Open manifest details modal (same as front page) 421 + - Delete: Confirm dialog with warning if manifest is tagged 422 + 423 + **Actions:** 424 + - Copy pull command for each tag 425 + - Edit tag (re-point to different digest) 426 + - Delete tag 427 + - Delete manifest (with validation) 428 + 429 + **API Endpoints:** 430 + 431 + ``` 432 + GET /ui/api/images 433 + Auth: Required 434 + Response: 435 + { 436 + "repositories": [ 437 + { 438 + "name": "nginx", 439 + "tag_count": 3, 440 + "manifest_count": 5, 441 + "last_push": "2025-10-05T10:23:45Z", 442 + "tags": [ 443 + { 444 + "tag": "latest", 445 + "digest": "sha256:abc123...", 446 + "created_at": "2025-10-05T10:23:45Z" 447 + } 448 + ], 449 + "manifests": [ 450 + { 451 + "digest": "sha256:abc123...", 452 + "size": 47185920, 453 + "hold_endpoint": "https://hold1.alice.com", 454 + "architecture": "amd64", 455 + "os": "linux", 456 + "layer_count": 5, 457 + "created_at": "2025-10-05T10:23:45Z", 458 + "tagged": true 459 + } 460 + ] 461 + } 462 + ] 463 + } 464 + 465 + PUT /ui/api/images/{repository}/tags/{tag} 466 + Auth: Required 467 + Body: 468 + { 469 + "digest": "sha256:new-digest..." 470 + } 471 + Response: 472 + { 473 + "success": true 474 + } 475 + 476 + DELETE /ui/api/images/{repository}/tags/{tag} 477 + Auth: Required 478 + Response: 479 + { 480 + "success": true 481 + } 482 + 483 + DELETE /ui/api/images/{repository}/manifests/{digest} 484 + Auth: Required 485 + Response: 486 + { 487 + "success": true 488 + } 489 + ``` 490 + 491 + ## Authentication 492 + 493 + ### OAuth Login Flow 494 + 495 + Reuse existing OAuth implementation from credential helper and AppView. 496 + 497 + **Login Endpoint:** `/auth/oauth/login` 498 + 499 + **Flow:** 500 + 1. User clicks "Login" on UI 501 + 2. Redirects to `/auth/oauth/login?return_to=/ui/images` 502 + 3. User enters handle (e.g., "alice.bsky.social") 503 + 4. Server resolves handle → DID → PDS → OAuth server 504 + 5. Server initiates OAuth flow with PAR + DPoP 505 + 6. User redirected to PDS for authorization 506 + 7. OAuth callback to `/auth/oauth/callback` 507 + 8. Server exchanges code for token, validates with PDS 508 + 9. Server creates session cookie (secure, httpOnly, SameSite) 509 + 10. Redirects to `return_to` URL or default `/ui/images` 510 + 511 + **Session Management:** 512 + - Session cookie: `atcr_session` (JWT or opaque token) 513 + - Session storage: In-memory map or SQLite table 514 + - Session duration: 24 hours (or match OAuth token expiry) 515 + - Refresh: Auto-refresh OAuth token when needed 516 + 517 + **Middleware:** 518 + ```go 519 + // pkg/ui/middleware/auth.go 520 + 521 + func RequireAuth(next http.Handler) http.Handler { 522 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 523 + session := getSession(r) 524 + if session == nil { 525 + http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 526 + return 527 + } 528 + 529 + // Add session info to context 530 + ctx := context.WithValue(r.Context(), "session", session) 531 + next.ServeHTTP(w, r.WithContext(ctx)) 532 + }) 533 + } 534 + ``` 535 + 536 + ## Implementation Roadmap 537 + 538 + ### Phase 1: Database & Firehose 539 + 1. Define SQLite schema 540 + 2. Implement database layer (pkg/ui/db/) 541 + 3. Implement firehose worker (pkg/ui/firehose/) 542 + 4. Test worker with real firehose 543 + 544 + ### Phase 2: API Endpoints 545 + 1. Implement `/ui/api/recent-pushes` (front page data) 546 + 2. Implement `/ui/api/profile` (settings page data) 547 + 3. Implement `/ui/api/images` (personal page data) 548 + 4. Implement tag/manifest mutation endpoints 549 + 550 + ### Phase 3: Authentication 551 + 1. Implement OAuth login endpoint 552 + 2. Implement session management 553 + 3. Add auth middleware 554 + 4. Test login flow 555 + 556 + ### Phase 4: Frontend 557 + 1. Choose framework (templates vs SPA) 558 + 2. Implement front page 559 + 3. Implement settings page 560 + 4. Implement personal page 561 + 5. Add styling 562 + 563 + ### Phase 5: Polish 564 + 1. Error handling 565 + 2. Loading states 566 + 3. Responsive design 567 + 4. Testing 568 + 569 + ## Open Questions 570 + 571 + 1. **Framework choice:** Go templates (Templ?), HTMX, or SPA (React/Vue)? 572 + 2. **Styling:** Tailwind, plain CSS, or component library? 573 + 3. **Manifest details:** Modal vs dedicated page? 574 + 4. **Search:** Full-text search on repository/tag names? Requires FTS in SQLite. 575 + 5. **Real-time updates:** WebSocket for firehose events, or polling? 576 + 6. **Image size calculation:** Sum of layer sizes, or read from manifest? 577 + 7. **Public profiles:** Should `/ui/@alice` show public view of alice's images? 578 + 8. **Firehose resilience:** Reconnect logic, backfill on downtime? 579 + 580 + ## Dependencies 581 + 582 + New Go packages needed: 583 + - `github.com/mattn/go-sqlite3` - SQLite driver 584 + - `github.com/bluesky-social/jetstream` - Firehose client (or direct websocket) 585 + - Session management library (or custom implementation) 586 + - Frontend framework (TBD) 587 + 588 + ## Configuration 589 + 590 + Add to `config/config.yml`: 591 + 592 + ```yaml 593 + ui: 594 + enabled: true 595 + database_path: /var/lib/atcr/ui.db 596 + firehose: 597 + enabled: true 598 + endpoint: wss://jetstream.atproto.tools/subscribe 599 + collections: 600 + - io.atcr.manifest 601 + - io.atcr.tag 602 + session: 603 + duration: 24h 604 + cookie_name: atcr_session 605 + cookie_secure: true 606 + ``` 607 + 608 + ## Security Considerations 609 + 610 + 1. **Session cookies:** Secure, HttpOnly, SameSite=Lax 611 + 2. **CSRF protection:** For mutation endpoints (tag/manifest delete) 612 + 3. **Rate limiting:** On API endpoints 613 + 4. **Input validation:** Sanitize user input for search/filters 614 + 5. **Authorization:** Verify authenticated user owns resources before mutation 615 + 6. **SQL injection:** Use parameterized queries 616 + 617 + ## Performance Considerations 618 + 619 + 1. **Database indexes:** On DID, repository, created_at, digest 620 + 2. **Pagination:** Limit query results to avoid large payloads 621 + 3. **Caching:** Cache profile data, hold list, manifest details 622 + 4. **Firehose buffering:** Batch database inserts 623 + 5. **Connection pooling:** For SQLite and HTTP clients 624 + 625 + ## Testing Strategy 626 + 627 + 1. **Unit tests:** Database layer, API handlers 628 + 2. **Integration tests:** Firehose worker with mock events 629 + 3. **E2E tests:** Full login → browse → manage flow 630 + 4. **Load testing:** Firehose worker with high event volume 631 + 5. **Manual testing:** Real PDS, real images, real firehose
+1
go.mod
··· 36 36 github.com/inconshreveable/mousetrap v1.1.0 // indirect 37 37 github.com/jmespath/go-jmespath v0.4.0 // indirect 38 38 github.com/klauspost/compress v1.17.11 // indirect 39 + github.com/mattn/go-sqlite3 v1.14.32 // indirect 39 40 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 40 41 github.com/opencontainers/image-spec v1.1.0 // indirect 41 42 github.com/prometheus/client_golang v1.20.5 // indirect
+2
go.sum
··· 102 102 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 103 103 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 104 104 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 105 + github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 106 + github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 105 107 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 106 108 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 107 109 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+84
pkg/appview/appview.go
··· 1 + package appview 2 + 3 + import ( 4 + "embed" 5 + "fmt" 6 + "html/template" 7 + "io/fs" 8 + "net/http" 9 + "time" 10 + ) 11 + 12 + //go:embed templates/**/*.html 13 + var templatesFS embed.FS 14 + 15 + //go:embed static/**/* 16 + var staticFS embed.FS 17 + 18 + // Templates returns parsed templates with helper functions 19 + func Templates() (*template.Template, error) { 20 + funcMap := template.FuncMap{ 21 + "timeAgo": func(t time.Time) string { 22 + duration := time.Since(t) 23 + 24 + if duration < time.Minute { 25 + return "just now" 26 + } else if duration < time.Hour { 27 + mins := int(duration.Minutes()) 28 + if mins == 1 { 29 + return "1 minute ago" 30 + } 31 + return fmt.Sprintf("%d minutes ago", mins) 32 + } else if duration < 24*time.Hour { 33 + hours := int(duration.Hours()) 34 + if hours == 1 { 35 + return "1 hour ago" 36 + } 37 + return fmt.Sprintf("%d hours ago", hours) 38 + } else { 39 + days := int(duration.Hours() / 24) 40 + if days == 1 { 41 + return "1 day ago" 42 + } 43 + return fmt.Sprintf("%d days ago", days) 44 + } 45 + }, 46 + 47 + "humanizeBytes": func(bytes int64) string { 48 + const unit = 1024 49 + if bytes < unit { 50 + return fmt.Sprintf("%d B", bytes) 51 + } 52 + div, exp := int64(unit), 0 53 + for n := bytes / unit; n >= unit; n /= unit { 54 + div *= unit 55 + exp++ 56 + } 57 + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 58 + }, 59 + 60 + "truncateDigest": func(digest string, length int) string { 61 + if len(digest) <= length { 62 + return digest 63 + } 64 + return digest[:length] + "..." 65 + }, 66 + } 67 + 68 + tmpl := template.New("").Funcs(funcMap) 69 + tmpl, err := tmpl.ParseFS(templatesFS, "templates/**/*.html") 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + return tmpl, nil 75 + } 76 + 77 + // StaticHandler returns HTTP handler for static files 78 + func StaticHandler() http.Handler { 79 + sub, err := fs.Sub(staticFS, "static") 80 + if err != nil { 81 + panic(err) 82 + } 83 + return http.FileServer(http.FS(sub)) 84 + }
+66
pkg/appview/db/models.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + // User represents a user in the system 6 + type User struct { 7 + DID string 8 + Handle string 9 + PDSEndpoint string 10 + LastSeen time.Time 11 + } 12 + 13 + // Manifest represents an OCI manifest stored in the cache 14 + type Manifest struct { 15 + ID int64 16 + DID string 17 + Repository string 18 + Digest string 19 + HoldEndpoint string 20 + SchemaVersion int 21 + MediaType string 22 + ConfigDigest string 23 + ConfigSize int64 24 + RawManifest string // JSON 25 + CreatedAt time.Time 26 + } 27 + 28 + // Layer represents a layer in a manifest 29 + type Layer struct { 30 + ManifestID int64 31 + Digest string 32 + Size int64 33 + MediaType string 34 + LayerIndex int 35 + } 36 + 37 + // Tag represents a tag pointing to a manifest 38 + type Tag struct { 39 + ID int64 40 + DID string 41 + Repository string 42 + Tag string 43 + Digest string 44 + CreatedAt time.Time 45 + } 46 + 47 + // Push represents a combined tag and manifest for the recent pushes view 48 + type Push struct { 49 + DID string 50 + Handle string 51 + Repository string 52 + Tag string 53 + Digest string 54 + HoldEndpoint string 55 + CreatedAt time.Time 56 + } 57 + 58 + // Repository represents an aggregated view of a user's repository 59 + type Repository struct { 60 + Name string 61 + TagCount int 62 + ManifestCount int 63 + LastPush time.Time 64 + Tags []Tag 65 + Manifests []Manifest 66 + }
+292
pkg/appview/db/queries.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + ) 6 + 7 + // GetRecentPushes fetches recent pushes with pagination 8 + func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { 9 + query := ` 10 + SELECT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at 11 + FROM tags t 12 + JOIN users u ON t.did = u.did 13 + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 14 + ` 15 + 16 + args := []interface{}{} 17 + 18 + if userFilter != "" { 19 + query += " WHERE u.handle = ? OR u.did = ?" 20 + args = append(args, userFilter, userFilter) 21 + } 22 + 23 + query += " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" 24 + args = append(args, limit, offset) 25 + 26 + rows, err := db.Query(query, args...) 27 + if err != nil { 28 + return nil, 0, err 29 + } 30 + defer rows.Close() 31 + 32 + var pushes []Push 33 + for rows.Next() { 34 + var p Push 35 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { 36 + return nil, 0, err 37 + } 38 + pushes = append(pushes, p) 39 + } 40 + 41 + // Get total count 42 + countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did" 43 + countArgs := []interface{}{} 44 + 45 + if userFilter != "" { 46 + countQuery += " WHERE u.handle = ? OR u.did = ?" 47 + countArgs = append(countArgs, userFilter, userFilter) 48 + } 49 + 50 + var total int 51 + if err := db.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { 52 + return nil, 0, err 53 + } 54 + 55 + return pushes, total, nil 56 + } 57 + 58 + // GetUserRepositories fetches all repositories for a user 59 + func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { 60 + // Get repository summary 61 + rows, err := db.Query(` 62 + SELECT 63 + repository, 64 + COUNT(DISTINCT tag) as tag_count, 65 + COUNT(DISTINCT digest) as manifest_count, 66 + MAX(created_at) as last_push 67 + FROM ( 68 + SELECT repository, tag, digest, created_at FROM tags WHERE did = ? 69 + UNION 70 + SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ? 71 + ) 72 + GROUP BY repository 73 + ORDER BY last_push DESC 74 + `, did, did) 75 + 76 + if err != nil { 77 + return nil, err 78 + } 79 + defer rows.Close() 80 + 81 + var repos []Repository 82 + for rows.Next() { 83 + var r Repository 84 + if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &r.LastPush); err != nil { 85 + return nil, err 86 + } 87 + 88 + // Get tags for this repo 89 + tagRows, err := db.Query(` 90 + SELECT id, tag, digest, created_at 91 + FROM tags 92 + WHERE did = ? AND repository = ? 93 + ORDER BY created_at DESC 94 + `, did, r.Name) 95 + 96 + if err != nil { 97 + return nil, err 98 + } 99 + 100 + for tagRows.Next() { 101 + var t Tag 102 + t.DID = did 103 + t.Repository = r.Name 104 + if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil { 105 + tagRows.Close() 106 + return nil, err 107 + } 108 + r.Tags = append(r.Tags, t) 109 + } 110 + tagRows.Close() 111 + 112 + // Get manifests for this repo 113 + manifestRows, err := db.Query(` 114 + SELECT id, digest, hold_endpoint, schema_version, media_type, 115 + config_digest, config_size, raw_manifest, created_at 116 + FROM manifests 117 + WHERE did = ? AND repository = ? 118 + ORDER BY created_at DESC 119 + `, did, r.Name) 120 + 121 + if err != nil { 122 + return nil, err 123 + } 124 + 125 + for manifestRows.Next() { 126 + var m Manifest 127 + m.DID = did 128 + m.Repository = r.Name 129 + if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 130 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt); err != nil { 131 + manifestRows.Close() 132 + return nil, err 133 + } 134 + r.Manifests = append(r.Manifests, m) 135 + } 136 + manifestRows.Close() 137 + 138 + repos = append(repos, r) 139 + } 140 + 141 + return repos, nil 142 + } 143 + 144 + // UpsertUser inserts or updates a user record 145 + func UpsertUser(db *sql.DB, user *User) error { 146 + _, err := db.Exec(` 147 + INSERT INTO users (did, handle, pds_endpoint, last_seen) 148 + VALUES (?, ?, ?, ?) 149 + ON CONFLICT(did) DO UPDATE SET 150 + handle = excluded.handle, 151 + pds_endpoint = excluded.pds_endpoint, 152 + last_seen = excluded.last_seen 153 + `, user.DID, user.Handle, user.PDSEndpoint, user.LastSeen) 154 + return err 155 + } 156 + 157 + // InsertManifest inserts a new manifest record 158 + func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) { 159 + result, err := db.Exec(` 160 + INSERT OR IGNORE INTO manifests 161 + (did, repository, digest, hold_endpoint, schema_version, media_type, 162 + config_digest, config_size, raw_manifest, created_at) 163 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 164 + `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 165 + manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 166 + manifest.ConfigSize, manifest.RawManifest, manifest.CreatedAt) 167 + 168 + if err != nil { 169 + return 0, err 170 + } 171 + 172 + return result.LastInsertId() 173 + } 174 + 175 + // InsertLayer inserts a new layer record 176 + func InsertLayer(db *sql.DB, layer *Layer) error { 177 + _, err := db.Exec(` 178 + INSERT INTO layers (manifest_id, digest, size, media_type, layer_index) 179 + VALUES (?, ?, ?, ?, ?) 180 + `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex) 181 + return err 182 + } 183 + 184 + // UpsertTag inserts or updates a tag record 185 + func UpsertTag(db *sql.DB, tag *Tag) error { 186 + _, err := db.Exec(` 187 + INSERT INTO tags (did, repository, tag, digest, created_at) 188 + VALUES (?, ?, ?, ?, ?) 189 + ON CONFLICT(did, repository, tag) DO UPDATE SET 190 + digest = excluded.digest, 191 + created_at = excluded.created_at 192 + `, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt) 193 + return err 194 + } 195 + 196 + // DeleteTag deletes a tag record 197 + func DeleteTag(db *sql.DB, did, repository, tag string) error { 198 + _, err := db.Exec(` 199 + DELETE FROM tags WHERE did = ? AND repository = ? AND tag = ? 200 + `, did, repository, tag) 201 + return err 202 + } 203 + 204 + // DeleteManifest deletes a manifest and its associated layers 205 + func DeleteManifest(db *sql.DB, did, repository, digest string) error { 206 + _, err := db.Exec(` 207 + DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ? 208 + `, did, repository, digest) 209 + return err 210 + } 211 + 212 + // GetManifest fetches a single manifest by digest 213 + func GetManifest(db *sql.DB, digest string) (*Manifest, error) { 214 + var m Manifest 215 + err := db.QueryRow(` 216 + SELECT id, did, repository, digest, hold_endpoint, schema_version, 217 + media_type, config_digest, config_size, raw_manifest, created_at 218 + FROM manifests 219 + WHERE digest = ? 220 + `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint, 221 + &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, 222 + &m.RawManifest, &m.CreatedAt) 223 + 224 + if err != nil { 225 + return nil, err 226 + } 227 + 228 + return &m, nil 229 + } 230 + 231 + // GetLayersForManifest fetches all layers for a manifest 232 + func GetLayersForManifest(db *sql.DB, manifestID int64) ([]Layer, error) { 233 + rows, err := db.Query(` 234 + SELECT manifest_id, digest, size, media_type, layer_index 235 + FROM layers 236 + WHERE manifest_id = ? 237 + ORDER BY layer_index 238 + `, manifestID) 239 + 240 + if err != nil { 241 + return nil, err 242 + } 243 + defer rows.Close() 244 + 245 + var layers []Layer 246 + for rows.Next() { 247 + var l Layer 248 + if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex); err != nil { 249 + return nil, err 250 + } 251 + layers = append(layers, l) 252 + } 253 + 254 + return layers, nil 255 + } 256 + 257 + // GetFirehoseCursor retrieves the current firehose cursor 258 + func GetFirehoseCursor(db *sql.DB) (int64, error) { 259 + var cursor int64 260 + err := db.QueryRow("SELECT cursor FROM firehose_cursor WHERE id = 1").Scan(&cursor) 261 + if err == sql.ErrNoRows { 262 + return 0, nil 263 + } 264 + return cursor, err 265 + } 266 + 267 + // UpdateFirehoseCursor updates the firehose cursor 268 + func UpdateFirehoseCursor(db *sql.DB, cursor int64) error { 269 + _, err := db.Exec(` 270 + INSERT INTO firehose_cursor (id, cursor, updated_at) 271 + VALUES (1, ?, datetime('now')) 272 + ON CONFLICT(id) DO UPDATE SET 273 + cursor = excluded.cursor, 274 + updated_at = excluded.updated_at 275 + `, cursor) 276 + return err 277 + } 278 + 279 + // IsManifestTagged checks if a manifest has any tags 280 + func IsManifestTagged(db *sql.DB, did, repository, digest string) (bool, error) { 281 + var count int 282 + err := db.QueryRow(` 283 + SELECT COUNT(*) FROM tags 284 + WHERE did = ? AND repository = ? AND digest = ? 285 + `, did, repository, digest).Scan(&count) 286 + 287 + if err != nil { 288 + return false, err 289 + } 290 + 291 + return count > 0, nil 292 + }
+86
pkg/appview/db/schema.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + 6 + _ "github.com/mattn/go-sqlite3" 7 + ) 8 + 9 + const schema = ` 10 + CREATE TABLE IF NOT EXISTS users ( 11 + did TEXT PRIMARY KEY, 12 + handle TEXT NOT NULL, 13 + pds_endpoint TEXT NOT NULL, 14 + last_seen TIMESTAMP NOT NULL, 15 + UNIQUE(handle) 16 + ); 17 + CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); 18 + 19 + CREATE TABLE IF NOT EXISTS manifests ( 20 + id INTEGER PRIMARY KEY AUTOINCREMENT, 21 + did TEXT NOT NULL, 22 + repository TEXT NOT NULL, 23 + digest TEXT NOT NULL, 24 + hold_endpoint TEXT NOT NULL, 25 + schema_version INTEGER NOT NULL, 26 + media_type TEXT NOT NULL, 27 + config_digest TEXT, 28 + config_size INTEGER, 29 + raw_manifest TEXT NOT NULL, 30 + created_at TIMESTAMP NOT NULL, 31 + UNIQUE(did, repository, digest), 32 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 33 + ); 34 + CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 35 + CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 36 + CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 37 + 38 + CREATE TABLE IF NOT EXISTS layers ( 39 + manifest_id INTEGER NOT NULL, 40 + digest TEXT NOT NULL, 41 + size INTEGER NOT NULL, 42 + media_type TEXT NOT NULL, 43 + layer_index INTEGER NOT NULL, 44 + PRIMARY KEY(manifest_id, layer_index), 45 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 46 + ); 47 + CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); 48 + 49 + CREATE TABLE IF NOT EXISTS tags ( 50 + id INTEGER PRIMARY KEY AUTOINCREMENT, 51 + did TEXT NOT NULL, 52 + repository TEXT NOT NULL, 53 + tag TEXT NOT NULL, 54 + digest TEXT NOT NULL, 55 + created_at TIMESTAMP NOT NULL, 56 + UNIQUE(did, repository, tag), 57 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 58 + ); 59 + CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository); 60 + 61 + CREATE TABLE IF NOT EXISTS firehose_cursor ( 62 + id INTEGER PRIMARY KEY CHECK (id = 1), 63 + cursor INTEGER NOT NULL, 64 + updated_at TIMESTAMP NOT NULL 65 + ); 66 + ` 67 + 68 + // InitDB initializes the SQLite database with the schema 69 + func InitDB(path string) (*sql.DB, error) { 70 + db, err := sql.Open("sqlite3", path) 71 + if err != nil { 72 + return nil, err 73 + } 74 + 75 + // Enable foreign keys 76 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 77 + return nil, err 78 + } 79 + 80 + // Create schema 81 + if _, err := db.Exec(schema); err != nil { 82 + return nil, err 83 + } 84 + 85 + return db, nil 86 + }
+63
pkg/appview/handlers/auth.go
··· 1 + package handlers 2 + 3 + import ( 4 + "html/template" 5 + "net/http" 6 + ) 7 + 8 + // LoginHandler shows the OAuth login form 9 + type LoginHandler struct { 10 + Templates *template.Template 11 + } 12 + 13 + func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 14 + returnTo := r.URL.Query().Get("return_to") 15 + if returnTo == "" { 16 + returnTo = "/" 17 + } 18 + 19 + data := struct { 20 + ReturnTo string 21 + Error string 22 + }{ 23 + ReturnTo: returnTo, 24 + Error: r.URL.Query().Get("error"), 25 + } 26 + 27 + if err := h.Templates.ExecuteTemplate(w, "login", data); err != nil { 28 + http.Error(w, err.Error(), http.StatusInternalServerError) 29 + return 30 + } 31 + } 32 + 33 + // LoginSubmitHandler processes the login form submission 34 + type LoginSubmitHandler struct{} 35 + 36 + func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 37 + if r.Method != http.MethodPost { 38 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 39 + return 40 + } 41 + 42 + handle := r.FormValue("handle") 43 + returnTo := r.FormValue("return_to") 44 + 45 + if handle == "" { 46 + http.Redirect(w, r, "/auth/oauth/login?return_to="+returnTo+"&error=handle_required", http.StatusFound) 47 + return 48 + } 49 + 50 + // Store return_to in cookie so callback can use it 51 + http.SetCookie(w, &http.Cookie{ 52 + Name: "oauth_return_to", 53 + Value: returnTo, 54 + Path: "/", 55 + MaxAge: 600, // 10 minutes 56 + HttpOnly: true, 57 + Secure: true, 58 + SameSite: http.SameSiteLaxMode, 59 + }) 60 + 61 + // Redirect to OAuth authorize with handle 62 + http.Redirect(w, r, "/auth/oauth/authorize?handle="+handle, http.StatusFound) 63 + }
+73
pkg/appview/handlers/home.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "html/template" 6 + "net/http" 7 + "strconv" 8 + 9 + "atcr.io/pkg/appview/db" 10 + "atcr.io/pkg/appview/middleware" 11 + ) 12 + 13 + // HomeHandler handles the home page 14 + type HomeHandler struct { 15 + DB *sql.DB 16 + Templates *template.Template 17 + } 18 + 19 + func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 + data := struct { 21 + User *db.User 22 + Query string 23 + }{ 24 + User: middleware.GetUser(r), 25 + Query: r.URL.Query().Get("q"), 26 + } 27 + 28 + if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil { 29 + http.Error(w, err.Error(), http.StatusInternalServerError) 30 + return 31 + } 32 + } 33 + 34 + // RecentPushesHandler handles the HTMX request for recent pushes 35 + type RecentPushesHandler struct { 36 + DB *sql.DB 37 + Templates *template.Template 38 + } 39 + 40 + func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 41 + limit := 50 42 + offset := 0 43 + 44 + if o := r.URL.Query().Get("offset"); o != "" { 45 + offset, _ = strconv.Atoi(o) 46 + } 47 + 48 + userFilter := r.URL.Query().Get("user") 49 + if userFilter == "" { 50 + userFilter = r.URL.Query().Get("q") 51 + } 52 + 53 + pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + data := struct { 60 + Pushes []db.Push 61 + HasMore bool 62 + NextOffset int 63 + }{ 64 + Pushes: pushes, 65 + HasMore: offset+limit < total, 66 + NextOffset: offset + limit, 67 + } 68 + 69 + if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil { 70 + http.Error(w, err.Error(), http.StatusInternalServerError) 71 + return 72 + } 73 + }
+116
pkg/appview/handlers/images.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "html/template" 6 + "net/http" 7 + 8 + "atcr.io/pkg/appview/db" 9 + "atcr.io/pkg/appview/middleware" 10 + "github.com/gorilla/mux" 11 + ) 12 + 13 + // ImagesHandler handles the images management page 14 + type ImagesHandler struct { 15 + DB *sql.DB 16 + Templates *template.Template 17 + } 18 + 19 + func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 + user := middleware.GetUser(r) 21 + if user == nil { 22 + http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound) 23 + return 24 + } 25 + 26 + // Fetch repositories from database (cached firehose data) 27 + repos, err := db.GetUserRepositories(h.DB, user.DID) 28 + if err != nil { 29 + http.Error(w, err.Error(), http.StatusInternalServerError) 30 + return 31 + } 32 + 33 + data := struct { 34 + User *db.User 35 + Repositories []db.Repository 36 + Query string 37 + }{ 38 + User: user, 39 + Repositories: repos, 40 + Query: r.URL.Query().Get("q"), 41 + } 42 + 43 + if err := h.Templates.ExecuteTemplate(w, "images", data); err != nil { 44 + http.Error(w, err.Error(), http.StatusInternalServerError) 45 + return 46 + } 47 + } 48 + 49 + // DeleteTagHandler handles deleting a tag 50 + type DeleteTagHandler struct { 51 + DB *sql.DB 52 + // TODO: Add ATProto client for deleting from PDS 53 + } 54 + 55 + func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 56 + user := middleware.GetUser(r) 57 + if user == nil { 58 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + vars := mux.Vars(r) 63 + repo := vars["repository"] 64 + tag := vars["tag"] 65 + 66 + // TODO: Delete from PDS via ATProto client 67 + 68 + // Delete from cache 69 + if err := db.DeleteTag(h.DB, user.DID, repo, tag); err != nil { 70 + http.Error(w, err.Error(), http.StatusInternalServerError) 71 + return 72 + } 73 + 74 + // Return empty response (HTMX will swap out the element) 75 + w.WriteHeader(http.StatusOK) 76 + } 77 + 78 + // DeleteManifestHandler handles deleting a manifest 79 + type DeleteManifestHandler struct { 80 + DB *sql.DB 81 + // TODO: Add ATProto client for deleting from PDS 82 + } 83 + 84 + func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 85 + user := middleware.GetUser(r) 86 + if user == nil { 87 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 88 + return 89 + } 90 + 91 + vars := mux.Vars(r) 92 + repo := vars["repository"] 93 + digest := vars["digest"] 94 + 95 + // Check if manifest is tagged 96 + tagged, err := db.IsManifestTagged(h.DB, user.DID, repo, digest) 97 + if err != nil { 98 + http.Error(w, err.Error(), http.StatusInternalServerError) 99 + return 100 + } 101 + 102 + if tagged { 103 + http.Error(w, "Cannot delete tagged manifest", http.StatusBadRequest) 104 + return 105 + } 106 + 107 + // TODO: Delete from PDS via ATProto client 108 + 109 + // Delete from cache 110 + if err := db.DeleteManifest(h.DB, user.DID, repo, digest); err != nil { 111 + http.Error(w, err.Error(), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + w.WriteHeader(http.StatusOK) 116 + }
+74
pkg/appview/handlers/settings.go
··· 1 + package handlers 2 + 3 + import ( 4 + "html/template" 5 + "net/http" 6 + "time" 7 + 8 + "atcr.io/pkg/appview/db" 9 + "atcr.io/pkg/appview/middleware" 10 + ) 11 + 12 + // SettingsHandler handles the settings page 13 + type SettingsHandler struct { 14 + Templates *template.Template 15 + // TODO: Add ATProto client when implementing profile fetching 16 + } 17 + 18 + func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 + user := middleware.GetUser(r) 20 + if user == nil { 21 + http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/settings", http.StatusFound) 22 + return 23 + } 24 + 25 + // TODO: Fetch actual profile from PDS using ATProto client 26 + // For now, using mock data from session 27 + data := struct { 28 + User *db.User 29 + Profile struct { 30 + Handle string 31 + DID string 32 + PDSEndpoint string 33 + DefaultHold string 34 + } 35 + SessionExpiry time.Time 36 + Query string 37 + }{ 38 + User: user, 39 + SessionExpiry: time.Now().Add(24 * time.Hour), // TODO: Get from actual session 40 + Query: r.URL.Query().Get("q"), 41 + } 42 + 43 + data.Profile.Handle = user.Handle 44 + data.Profile.DID = user.DID 45 + data.Profile.PDSEndpoint = user.PDSEndpoint 46 + // data.Profile.DefaultHold will be empty for now 47 + 48 + if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { 49 + http.Error(w, err.Error(), http.StatusInternalServerError) 50 + return 51 + } 52 + } 53 + 54 + // UpdateDefaultHoldHandler handles updating the default hold 55 + type UpdateDefaultHoldHandler struct { 56 + // TODO: Add ATProto client for updating profile 57 + } 58 + 59 + func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 60 + user := middleware.GetUser(r) 61 + if user == nil { 62 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 63 + return 64 + } 65 + 66 + holdEndpoint := r.FormValue("hold_endpoint") 67 + 68 + // TODO: Update profile in PDS via ATProto client 69 + // For now, just return success 70 + _ = holdEndpoint 71 + 72 + w.Header().Set("Content-Type", "text/html") 73 + w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`)) 74 + }
+69
pkg/appview/middleware/auth.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + 7 + "atcr.io/pkg/appview/db" 8 + "atcr.io/pkg/appview/session" 9 + ) 10 + 11 + type contextKey string 12 + 13 + const userKey contextKey = "user" 14 + 15 + // RequireAuth is middleware that requires authentication 16 + func RequireAuth(store *session.Store) func(http.Handler) http.Handler { 17 + return func(next http.Handler) http.Handler { 18 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 + sessionID, ok := session.GetSessionID(r) 20 + if !ok { 21 + http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 22 + return 23 + } 24 + 25 + sess, ok := store.Get(sessionID) 26 + if !ok { 27 + http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 28 + return 29 + } 30 + 31 + user := &db.User{ 32 + DID: sess.DID, 33 + Handle: sess.Handle, 34 + } 35 + 36 + ctx := context.WithValue(r.Context(), userKey, user) 37 + next.ServeHTTP(w, r.WithContext(ctx)) 38 + }) 39 + } 40 + } 41 + 42 + // OptionalAuth is middleware that optionally includes user if authenticated 43 + func OptionalAuth(store *session.Store) func(http.Handler) http.Handler { 44 + return func(next http.Handler) http.Handler { 45 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 + sessionID, ok := session.GetSessionID(r) 47 + if ok { 48 + if sess, ok := store.Get(sessionID); ok { 49 + user := &db.User{ 50 + DID: sess.DID, 51 + Handle: sess.Handle, 52 + } 53 + ctx := context.WithValue(r.Context(), userKey, user) 54 + r = r.WithContext(ctx) 55 + } 56 + } 57 + next.ServeHTTP(w, r) 58 + }) 59 + } 60 + } 61 + 62 + // GetUser retrieves the user from the request context 63 + func GetUser(r *http.Request) *db.User { 64 + user, ok := r.Context().Value(userKey).(*db.User) 65 + if !ok { 66 + return nil 67 + } 68 + return user 69 + }
+121
pkg/appview/session/session.go
··· 1 + package session 2 + 3 + import ( 4 + "crypto/rand" 5 + "encoding/base64" 6 + "net/http" 7 + "sync" 8 + "time" 9 + ) 10 + 11 + // Session represents a user session 12 + type Session struct { 13 + ID string 14 + DID string 15 + Handle string 16 + ExpiresAt time.Time 17 + } 18 + 19 + // Store manages user sessions 20 + type Store struct { 21 + mu sync.RWMutex 22 + sessions map[string]*Session 23 + } 24 + 25 + // NewStore creates a new session store 26 + func NewStore() *Store { 27 + return &Store{ 28 + sessions: make(map[string]*Session), 29 + } 30 + } 31 + 32 + // Create creates a new session and returns the full Session struct 33 + func (s *Store) Create(did, handle string, duration time.Duration) (string, error) { 34 + s.mu.Lock() 35 + defer s.mu.Unlock() 36 + 37 + // Generate random session ID 38 + b := make([]byte, 32) 39 + if _, err := rand.Read(b); err != nil { 40 + return "", err 41 + } 42 + 43 + sess := &Session{ 44 + ID: base64.URLEncoding.EncodeToString(b), 45 + DID: did, 46 + Handle: handle, 47 + ExpiresAt: time.Now().Add(duration), 48 + } 49 + 50 + s.sessions[sess.ID] = sess 51 + return sess.ID, nil 52 + } 53 + 54 + // Get retrieves a session by ID 55 + func (s *Store) Get(id string) (*Session, bool) { 56 + s.mu.RLock() 57 + defer s.mu.RUnlock() 58 + 59 + sess, ok := s.sessions[id] 60 + if !ok || time.Now().After(sess.ExpiresAt) { 61 + return nil, false 62 + } 63 + 64 + return sess, true 65 + } 66 + 67 + // Delete removes a session 68 + func (s *Store) Delete(id string) { 69 + s.mu.Lock() 70 + defer s.mu.Unlock() 71 + 72 + delete(s.sessions, id) 73 + } 74 + 75 + // Cleanup removes expired sessions 76 + func (s *Store) Cleanup() { 77 + s.mu.Lock() 78 + defer s.mu.Unlock() 79 + 80 + now := time.Now() 81 + for id, sess := range s.sessions { 82 + if now.After(sess.ExpiresAt) { 83 + delete(s.sessions, id) 84 + } 85 + } 86 + } 87 + 88 + // SetCookie sets the session cookie 89 + func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) { 90 + http.SetCookie(w, &http.Cookie{ 91 + Name: "atcr_session", 92 + Value: sessionID, 93 + Path: "/", 94 + MaxAge: maxAge, 95 + HttpOnly: true, 96 + Secure: true, 97 + SameSite: http.SameSiteLaxMode, 98 + }) 99 + } 100 + 101 + // ClearCookie clears the session cookie 102 + func ClearCookie(w http.ResponseWriter) { 103 + http.SetCookie(w, &http.Cookie{ 104 + Name: "atcr_session", 105 + Value: "", 106 + Path: "/", 107 + MaxAge: -1, 108 + HttpOnly: true, 109 + Secure: true, 110 + SameSite: http.SameSiteLaxMode, 111 + }) 112 + } 113 + 114 + // GetSessionID gets session ID from cookie 115 + func GetSessionID(r *http.Request) (string, bool) { 116 + cookie, err := r.Cookie("atcr_session") 117 + if err != nil { 118 + return "", false 119 + } 120 + return cookie.Value, true 121 + }
+547
pkg/appview/static/css/style.css
··· 1 + :root { 2 + --primary: #0066cc; 3 + --secondary: #6c757d; 4 + --success: #28a745; 5 + --danger: #dc3545; 6 + --bg: #ffffff; 7 + --fg: #1a1a1a; 8 + --border: #e0e0e0; 9 + --code-bg: #f5f5f5; 10 + --hover-bg: #f9f9f9; 11 + } 12 + 13 + * { 14 + margin: 0; 15 + padding: 0; 16 + box-sizing: border-box; 17 + } 18 + 19 + body { 20 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 21 + background: var(--bg); 22 + color: var(--fg); 23 + line-height: 1.6; 24 + } 25 + 26 + .container { 27 + max-width: 1200px; 28 + margin: 0 auto; 29 + padding: 20px; 30 + } 31 + 32 + /* Navigation */ 33 + .navbar { 34 + background: var(--fg); 35 + color: white; 36 + padding: 1rem 2rem; 37 + display: flex; 38 + justify-content: space-between; 39 + align-items: center; 40 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 41 + } 42 + 43 + .nav-brand a { 44 + color: white; 45 + text-decoration: none; 46 + font-size: 1.5rem; 47 + font-weight: bold; 48 + } 49 + 50 + .nav-search { 51 + flex: 1; 52 + max-width: 400px; 53 + margin: 0 2rem; 54 + } 55 + 56 + .nav-search input { 57 + width: 100%; 58 + padding: 0.5rem 1rem; 59 + border: none; 60 + border-radius: 4px; 61 + font-size: 0.95rem; 62 + } 63 + 64 + .nav-links { 65 + display: flex; 66 + gap: 1rem; 67 + align-items: center; 68 + } 69 + 70 + .nav-links a { 71 + color: white; 72 + text-decoration: none; 73 + padding: 0.5rem 1rem; 74 + } 75 + 76 + .nav-links a:hover { 77 + background: rgba(255, 255, 255, 0.1); 78 + border-radius: 4px; 79 + } 80 + 81 + .user-handle { 82 + color: #aaa; 83 + } 84 + 85 + .settings-icon { 86 + font-size: 1.2rem; 87 + } 88 + 89 + /* Buttons */ 90 + button, .btn, .btn-primary, .btn-secondary { 91 + padding: 0.5rem 1rem; 92 + background: var(--primary); 93 + color: white; 94 + border: none; 95 + border-radius: 4px; 96 + cursor: pointer; 97 + text-decoration: none; 98 + display: inline-block; 99 + font-size: 0.95rem; 100 + transition: opacity 0.2s; 101 + } 102 + 103 + button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { 104 + opacity: 0.9; 105 + } 106 + 107 + .btn-secondary { 108 + background: var(--secondary); 109 + } 110 + 111 + .btn-link { 112 + background: transparent; 113 + color: white; 114 + text-decoration: underline; 115 + } 116 + 117 + .delete-btn { 118 + background: var(--danger); 119 + padding: 0.25rem 0.5rem; 120 + font-size: 0.85rem; 121 + } 122 + 123 + .copy-btn { 124 + padding: 0.25rem 0.75rem; 125 + background: var(--primary); 126 + font-size: 0.85rem; 127 + } 128 + 129 + /* Cards */ 130 + .push-card, .repository-card { 131 + border: 1px solid var(--border); 132 + border-radius: 8px; 133 + padding: 1rem; 134 + margin-bottom: 1rem; 135 + background: white; 136 + box-shadow: 0 1px 3px rgba(0,0,0,0.05); 137 + } 138 + 139 + .push-header { 140 + font-size: 1.1rem; 141 + margin-bottom: 0.5rem; 142 + } 143 + 144 + .push-user { 145 + color: var(--primary); 146 + text-decoration: none; 147 + font-weight: 500; 148 + } 149 + 150 + .push-user:hover { 151 + text-decoration: underline; 152 + } 153 + 154 + .push-separator { 155 + color: #999; 156 + margin: 0 0.25rem; 157 + } 158 + 159 + .push-repo { 160 + font-weight: 500; 161 + } 162 + 163 + .push-tag { 164 + color: var(--secondary); 165 + } 166 + 167 + .push-details { 168 + display: flex; 169 + gap: 0.5rem; 170 + align-items: center; 171 + color: #666; 172 + font-size: 0.9rem; 173 + margin-bottom: 0.5rem; 174 + } 175 + 176 + .digest { 177 + font-family: 'Monaco', 'Courier New', monospace; 178 + font-size: 0.85rem; 179 + background: var(--code-bg); 180 + padding: 0.1rem 0.3rem; 181 + border-radius: 3px; 182 + } 183 + 184 + .separator { 185 + color: #ccc; 186 + } 187 + 188 + .push-command { 189 + display: flex; 190 + gap: 0.5rem; 191 + align-items: center; 192 + margin-top: 0.5rem; 193 + padding: 0.5rem; 194 + background: var(--code-bg); 195 + border-radius: 4px; 196 + } 197 + 198 + .pull-command { 199 + flex: 1; 200 + font-family: 'Monaco', 'Courier New', monospace; 201 + font-size: 0.9rem; 202 + } 203 + 204 + /* Repository Cards */ 205 + .repo-header { 206 + padding: 1rem; 207 + cursor: pointer; 208 + display: flex; 209 + justify-content: space-between; 210 + align-items: center; 211 + background: var(--hover-bg); 212 + border-radius: 8px 8px 0 0; 213 + margin: -1rem -1rem 0 -1rem; 214 + } 215 + 216 + .repo-header:hover { 217 + background: #f0f0f0; 218 + } 219 + 220 + .repo-header h2 { 221 + font-size: 1.3rem; 222 + margin-bottom: 0.25rem; 223 + } 224 + 225 + .repo-stats { 226 + color: #666; 227 + font-size: 0.9rem; 228 + display: flex; 229 + gap: 0.5rem; 230 + } 231 + 232 + .expand-btn { 233 + background: transparent; 234 + color: var(--fg); 235 + padding: 0.25rem 0.5rem; 236 + font-size: 1.2rem; 237 + } 238 + 239 + .repo-details { 240 + padding-top: 1rem; 241 + } 242 + 243 + .tags-section, .manifests-section { 244 + margin-bottom: 1.5rem; 245 + } 246 + 247 + .tags-section h3, .manifests-section h3 { 248 + font-size: 1.1rem; 249 + margin-bottom: 0.5rem; 250 + color: var(--secondary); 251 + } 252 + 253 + .tag-row, .manifest-row { 254 + display: flex; 255 + gap: 1rem; 256 + align-items: center; 257 + padding: 0.5rem; 258 + border-bottom: 1px solid var(--border); 259 + } 260 + 261 + .tag-row:last-child, .manifest-row:last-child { 262 + border-bottom: none; 263 + } 264 + 265 + .tag-name { 266 + font-weight: 500; 267 + min-width: 100px; 268 + } 269 + 270 + .tag-arrow { 271 + color: #999; 272 + } 273 + 274 + .tag-digest, .manifest-digest { 275 + font-family: 'Monaco', 'Courier New', monospace; 276 + font-size: 0.85rem; 277 + background: var(--code-bg); 278 + padding: 0.1rem 0.3rem; 279 + border-radius: 3px; 280 + } 281 + 282 + /* Settings Page */ 283 + .settings-page { 284 + max-width: 800px; 285 + margin: 0 auto; 286 + } 287 + 288 + .settings-section { 289 + background: white; 290 + border: 1px solid var(--border); 291 + border-radius: 8px; 292 + padding: 1.5rem; 293 + margin-bottom: 1.5rem; 294 + box-shadow: 0 1px 3px rgba(0,0,0,0.05); 295 + } 296 + 297 + .settings-section h2 { 298 + font-size: 1.3rem; 299 + margin-bottom: 1rem; 300 + padding-bottom: 0.5rem; 301 + border-bottom: 2px solid var(--border); 302 + } 303 + 304 + .form-group { 305 + margin-bottom: 1rem; 306 + } 307 + 308 + .form-group label { 309 + display: block; 310 + margin-bottom: 0.5rem; 311 + font-weight: 500; 312 + color: var(--secondary); 313 + } 314 + 315 + .form-group input, 316 + .form-group select { 317 + width: 100%; 318 + padding: 0.5rem; 319 + border: 1px solid var(--border); 320 + border-radius: 4px; 321 + font-size: 1rem; 322 + } 323 + 324 + .form-group small { 325 + display: block; 326 + margin-top: 0.25rem; 327 + color: #666; 328 + font-size: 0.85rem; 329 + } 330 + 331 + .info-row { 332 + margin-bottom: 0.75rem; 333 + } 334 + 335 + .info-row strong { 336 + display: inline-block; 337 + min-width: 150px; 338 + color: var(--secondary); 339 + } 340 + 341 + /* Modal */ 342 + .modal-overlay { 343 + position: fixed; 344 + top: 0; 345 + left: 0; 346 + right: 0; 347 + bottom: 0; 348 + background: rgba(0, 0, 0, 0.6); 349 + display: flex; 350 + justify-content: center; 351 + align-items: center; 352 + z-index: 1000; 353 + } 354 + 355 + .modal-content { 356 + background: white; 357 + padding: 2rem; 358 + border-radius: 8px; 359 + max-width: 800px; 360 + max-height: 80vh; 361 + overflow-y: auto; 362 + position: relative; 363 + box-shadow: 0 4px 6px rgba(0,0,0,0.1); 364 + } 365 + 366 + .modal-close { 367 + position: absolute; 368 + top: 1rem; 369 + right: 1rem; 370 + background: none; 371 + border: none; 372 + font-size: 1.5rem; 373 + cursor: pointer; 374 + color: var(--secondary); 375 + } 376 + 377 + .modal-close:hover { 378 + color: var(--fg); 379 + } 380 + 381 + .manifest-json { 382 + background: var(--code-bg); 383 + padding: 1rem; 384 + border-radius: 4px; 385 + overflow-x: auto; 386 + font-family: 'Monaco', 'Courier New', monospace; 387 + font-size: 0.85rem; 388 + border: 1px solid var(--border); 389 + } 390 + 391 + /* Loading and Empty States */ 392 + .loading { 393 + text-align: center; 394 + padding: 2rem; 395 + color: #666; 396 + } 397 + 398 + .empty-state { 399 + text-align: center; 400 + padding: 3rem 2rem; 401 + background: var(--hover-bg); 402 + border-radius: 8px; 403 + border: 1px solid var(--border); 404 + } 405 + 406 + .empty-state p { 407 + margin-bottom: 1rem; 408 + font-size: 1.1rem; 409 + color: var(--secondary); 410 + } 411 + 412 + .empty-state pre { 413 + background: var(--code-bg); 414 + padding: 1rem; 415 + border-radius: 4px; 416 + display: inline-block; 417 + } 418 + 419 + .empty-message { 420 + color: #999; 421 + font-style: italic; 422 + padding: 1rem; 423 + } 424 + 425 + /* Status Messages */ 426 + .success { 427 + color: var(--success); 428 + padding: 0.5rem; 429 + background: #d4edda; 430 + border: 1px solid #c3e6cb; 431 + border-radius: 4px; 432 + margin-top: 1rem; 433 + } 434 + 435 + .error { 436 + color: var(--danger); 437 + padding: 0.5rem; 438 + background: #f8d7da; 439 + border: 1px solid #f5c6cb; 440 + border-radius: 4px; 441 + margin-top: 1rem; 442 + } 443 + 444 + /* Load More Button */ 445 + .load-more { 446 + width: 100%; 447 + margin-top: 1rem; 448 + background: var(--secondary); 449 + } 450 + 451 + /* Login Page */ 452 + .login-page { 453 + max-width: 450px; 454 + margin: 4rem auto; 455 + padding: 2rem; 456 + } 457 + 458 + .login-page h1 { 459 + font-size: 2rem; 460 + margin-bottom: 0.5rem; 461 + text-align: center; 462 + } 463 + 464 + .login-page > p { 465 + text-align: center; 466 + color: var(--secondary); 467 + margin-bottom: 2rem; 468 + } 469 + 470 + .login-form { 471 + background: white; 472 + padding: 2rem; 473 + border-radius: 8px; 474 + border: 1px solid var(--border); 475 + box-shadow: 0 2px 4px rgba(0,0,0,0.05); 476 + } 477 + 478 + .login-form .form-group { 479 + margin-bottom: 1.5rem; 480 + } 481 + 482 + .login-form label { 483 + display: block; 484 + margin-bottom: 0.5rem; 485 + font-weight: 500; 486 + } 487 + 488 + .login-form input[type="text"] { 489 + width: 100%; 490 + padding: 0.75rem; 491 + border: 1px solid var(--border); 492 + border-radius: 4px; 493 + font-size: 1rem; 494 + } 495 + 496 + .login-form input[type="text"]:focus { 497 + outline: none; 498 + border-color: var(--primary); 499 + } 500 + 501 + .btn-large { 502 + width: 100%; 503 + padding: 0.75rem 1.5rem; 504 + font-size: 1rem; 505 + font-weight: 500; 506 + } 507 + 508 + .login-help { 509 + text-align: center; 510 + margin-top: 2rem; 511 + color: var(--secondary); 512 + } 513 + 514 + .login-help a { 515 + color: var(--primary); 516 + text-decoration: none; 517 + } 518 + 519 + .login-help a:hover { 520 + text-decoration: underline; 521 + } 522 + 523 + /* Responsive */ 524 + @media (max-width: 768px) { 525 + .navbar { 526 + flex-direction: column; 527 + gap: 1rem; 528 + } 529 + 530 + .nav-search { 531 + max-width: 100%; 532 + margin: 0; 533 + } 534 + 535 + .push-details { 536 + flex-wrap: wrap; 537 + } 538 + 539 + .tag-row, .manifest-row { 540 + flex-wrap: wrap; 541 + } 542 + 543 + .login-page { 544 + margin: 2rem auto; 545 + padding: 1rem; 546 + } 547 + }
+60
pkg/appview/static/js/app.js
··· 1 + // Copy to clipboard 2 + function copyToClipboard(text) { 3 + navigator.clipboard.writeText(text).then(() => { 4 + // Show success feedback 5 + const btn = event.target; 6 + const originalText = btn.textContent; 7 + btn.textContent = '✓ Copied!'; 8 + setTimeout(() => { 9 + btn.textContent = originalText; 10 + }, 2000); 11 + }).catch(err => { 12 + console.error('Failed to copy:', err); 13 + }); 14 + } 15 + 16 + // Time ago helper (for client-side rendering) 17 + function timeAgo(date) { 18 + const seconds = Math.floor((new Date() - new Date(date)) / 1000); 19 + 20 + const intervals = { 21 + year: 31536000, 22 + month: 2592000, 23 + week: 604800, 24 + day: 86400, 25 + hour: 3600, 26 + minute: 60, 27 + second: 1 28 + }; 29 + 30 + for (const [name, secondsInInterval] of Object.entries(intervals)) { 31 + const interval = Math.floor(seconds / secondsInInterval); 32 + if (interval >= 1) { 33 + return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`; 34 + } 35 + } 36 + 37 + return 'just now'; 38 + } 39 + 40 + // Update timestamps on page load and HTMX swaps 41 + function updateTimestamps() { 42 + document.querySelectorAll('time[datetime]').forEach(el => { 43 + const date = el.getAttribute('datetime'); 44 + if (date && !el.dataset.noUpdate) { 45 + const ago = timeAgo(date); 46 + if (el.textContent !== ago) { 47 + el.textContent = ago; 48 + } 49 + } 50 + }); 51 + } 52 + 53 + // Initial timestamp update 54 + document.addEventListener('DOMContentLoaded', updateTimestamps); 55 + 56 + // Update timestamps after HTMX swaps 57 + document.addEventListener('htmx:afterSwap', updateTimestamps); 58 + 59 + // Update timestamps periodically 60 + setInterval(updateTimestamps, 60000); // Every minute
+33
pkg/appview/templates/components/modal.html
··· 1 + {{ define "manifest-modal" }} 2 + <div class="modal-overlay" onclick="this.remove()"> 3 + <div class="modal-content" onclick="event.stopPropagation()"> 4 + <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button> 5 + 6 + <h2>Manifest Details</h2> 7 + 8 + <div class="manifest-info"> 9 + <div class="info-row"> 10 + <strong>Digest:</strong> 11 + <code>{{ .Digest }}</code> 12 + </div> 13 + <div class="info-row"> 14 + <strong>Media Type:</strong> 15 + <span>{{ .MediaType }}</span> 16 + </div> 17 + <div class="info-row"> 18 + <strong>Hold Endpoint:</strong> 19 + <span>{{ .HoldEndpoint }}</span> 20 + </div> 21 + <div class="info-row"> 22 + <strong>Created:</strong> 23 + <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 24 + {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }} 25 + </time> 26 + </div> 27 + </div> 28 + 29 + <h3>Raw Manifest</h3> 30 + <pre class="manifest-json"><code>{{ .RawManifest }}</code></pre> 31 + </div> 32 + </div> 33 + {{ end }}
+26
pkg/appview/templates/components/nav.html
··· 1 + {{ define "nav" }} 2 + <nav class="navbar"> 3 + <div class="nav-brand"> 4 + <a href="/">ATCR</a> 5 + </div> 6 + 7 + <div class="nav-search"> 8 + <form action="/" method="get"> 9 + <input type="text" name="q" placeholder="Search images..." value="{{ .Query }}" /> 10 + </form> 11 + </div> 12 + 13 + <div class="nav-links"> 14 + {{ if .User }} 15 + <a href="/images">Your Images</a> 16 + <span class="user-handle">@{{ .User.Handle }}</span> 17 + <a href="/settings" class="settings-icon" title="Settings">⚙️</a> 18 + <form action="/auth/logout" method="POST" style="display: inline;"> 19 + <button type="submit" class="btn-link">Logout</button> 20 + </form> 21 + {{ else }} 22 + <a href="/auth/oauth/login?return_to=/" class="btn-primary">Login</a> 23 + {{ end }} 24 + </div> 25 + </nav> 26 + {{ end }}
+24
pkg/appview/templates/layouts/base.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>{{ block "title" . }}ATCR - Federated Container Registry{{ end }}</title> 7 + <link rel="stylesheet" href="/static/css/style.css"> 8 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 9 + {{ block "head" . }}{{ end }} 10 + </head> 11 + <body> 12 + {{ template "nav" . }} 13 + 14 + <main class="container"> 15 + {{ block "content" . }}{{ end }} 16 + </main> 17 + 18 + <!-- Modal container for HTMX --> 19 + <div id="modal"></div> 20 + 21 + <script src="/static/js/app.js"></script> 22 + {{ block "scripts" . }}{{ end }} 23 + </body> 24 + </html>
+31
pkg/appview/templates/pages/home.html
··· 1 + {{ define "home" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>ATCR - Federated Container Registry</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + </head> 11 + <body> 12 + {{ template "nav" . }} 13 + 14 + <main class="container"> 15 + <div class="home-page"> 16 + <h1>Recent Pushes</h1> 17 + 18 + <div id="push-list" hx-get="/api/recent-pushes" hx-trigger="load" hx-swap="innerHTML"> 19 + <!-- Initial loading state --> 20 + <div class="loading">Loading recent pushes...</div> 21 + </div> 22 + </div> 23 + </main> 24 + 25 + <!-- Modal container for HTMX --> 26 + <div id="modal"></div> 27 + 28 + <script src="/static/js/app.js"></script> 29 + </body> 30 + </html> 31 + {{ end }}
+115
pkg/appview/templates/pages/images.html
··· 1 + {{ define "images" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Your Images - ATCR</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + </head> 11 + <body> 12 + {{ template "nav" . }} 13 + 14 + <main class="container"> 15 + <div class="images-page"> 16 + <h1>Your Images</h1> 17 + 18 + {{ if .Repositories }} 19 + {{ range .Repositories }} 20 + <div class="repository-card"> 21 + <div class="repo-header" onclick="toggleRepo('{{ .Name }}')"> 22 + <div> 23 + <h2>{{ .Name }}</h2> 24 + <div class="repo-stats"> 25 + <span>{{ .TagCount }} tags</span> 26 + <span>•</span> 27 + <span>{{ .ManifestCount }} manifests</span> 28 + <span>•</span> 29 + <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}"> 30 + Last push: {{ timeAgo .LastPush }} 31 + </time> 32 + </div> 33 + </div> 34 + <button class="expand-btn" id="btn-{{ .Name }}">▼</button> 35 + </div> 36 + 37 + <div id="repo-{{ .Name }}" class="repo-details" style="display: none;"> 38 + <!-- Tags Section --> 39 + <div class="tags-section"> 40 + <h3>Tags</h3> 41 + {{ if .Tags }} 42 + {{ range .Tags }} 43 + <div class="tag-row" id="tag-{{ $.Name }}-{{ .Tag }}"> 44 + <span class="tag-name">{{ .Tag }}</span> 45 + <span class="tag-arrow">→</span> 46 + <code class="tag-digest">{{ truncateDigest .Digest 12 }}</code> 47 + <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 48 + {{ timeAgo .CreatedAt }} 49 + </time> 50 + 51 + <button class="delete-btn" 52 + hx-delete="/api/images/{{ $.Name }}/tags/{{ .Tag }}" 53 + hx-confirm="Delete tag {{ .Tag }}?" 54 + hx-target="#tag-{{ $.Name }}-{{ .Tag }}" 55 + hx-swap="outerHTML"> 56 + 🗑️ 57 + </button> 58 + </div> 59 + {{ end }} 60 + {{ else }} 61 + <p class="empty-message">No tags for this repository</p> 62 + {{ end }} 63 + </div> 64 + 65 + <!-- Manifests Section --> 66 + <div class="manifests-section"> 67 + <h3>Manifests</h3> 68 + {{ if .Manifests }} 69 + {{ range .Manifests }} 70 + <div class="manifest-row" id="manifest-{{ .Digest }}"> 71 + <code class="manifest-digest">{{ truncateDigest .Digest 12 }}</code> 72 + <span>{{ .HoldEndpoint }}</span> 73 + <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 74 + {{ timeAgo .CreatedAt }} 75 + </time> 76 + </div> 77 + {{ end }} 78 + {{ else }} 79 + <p class="empty-message">No manifests for this repository</p> 80 + {{ end }} 81 + </div> 82 + </div> 83 + </div> 84 + {{ end }} 85 + {{ else }} 86 + <div class="empty-state"> 87 + <p>No images yet. Push your first image:</p> 88 + <pre><code>docker push atcr.io/{{ .User.Handle }}/myapp:latest</code></pre> 89 + </div> 90 + {{ end }} 91 + </div> 92 + </main> 93 + 94 + <!-- Modal container for HTMX --> 95 + <div id="modal"></div> 96 + 97 + <script src="/static/js/app.js"></script> 98 + <script> 99 + // Toggle repository details 100 + function toggleRepo(name) { 101 + const details = document.getElementById('repo-' + name); 102 + const btn = document.getElementById('btn-' + name); 103 + 104 + if (details.style.display === 'none') { 105 + details.style.display = 'block'; 106 + btn.textContent = '▲'; 107 + } else { 108 + details.style.display = 'none'; 109 + btn.textContent = '▼'; 110 + } 111 + } 112 + </script> 113 + </body> 114 + </html> 115 + {{ end }}
+58
pkg/appview/templates/pages/login.html
··· 1 + {{ define "login" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Login - ATCR</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + </head> 10 + <body> 11 + <nav class="navbar"> 12 + <div class="nav-brand"> 13 + <a href="/">ATCR</a> 14 + </div> 15 + </nav> 16 + 17 + <main class="container"> 18 + <div class="login-page"> 19 + <h1>Sign in to ATCR</h1> 20 + <p>Use your ATProto handle to sign in</p> 21 + 22 + {{ if .Error }} 23 + <div class="error"> 24 + {{ if eq .Error "handle_required" }} 25 + Please enter your handle 26 + {{ else if eq .Error "auth_failed" }} 27 + Authentication failed. Please try again. 28 + {{ else }} 29 + An error occurred. Please try again. 30 + {{ end }} 31 + </div> 32 + {{ end }} 33 + 34 + <form action="/auth/oauth/login" method="POST" class="login-form"> 35 + <input type="hidden" name="return_to" value="{{ .ReturnTo }}" /> 36 + 37 + <div class="form-group"> 38 + <label for="handle">Your ATProto Handle</label> 39 + <input type="text" 40 + id="handle" 41 + name="handle" 42 + placeholder="alice.bsky.social" 43 + required 44 + autofocus /> 45 + <small>Enter your Bluesky or ATProto handle</small> 46 + </div> 47 + 48 + <button type="submit" class="btn-primary btn-large">Continue with ATProto</button> 49 + </form> 50 + 51 + <div class="login-help"> 52 + <p>Don't have an account? Create one at <a href="https://bsky.app" target="_blank">bsky.app</a></p> 53 + </div> 54 + </div> 55 + </main> 56 + </body> 57 + </html> 58 + {{ end }}
+84
pkg/appview/templates/pages/settings.html
··· 1 + {{ define "settings" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Settings - ATCR</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + </head> 11 + <body> 12 + {{ template "nav" . }} 13 + 14 + <main class="container"> 15 + <div class="settings-page"> 16 + <h1>Settings</h1> 17 + 18 + <!-- Identity Section --> 19 + <section class="settings-section"> 20 + <h2>Identity</h2> 21 + <div class="form-group"> 22 + <label>Handle:</label> 23 + <span>{{ .Profile.Handle }}</span> 24 + </div> 25 + <div class="form-group"> 26 + <label>DID:</label> 27 + <code>{{ .Profile.DID }}</code> 28 + </div> 29 + <div class="form-group"> 30 + <label>PDS:</label> 31 + <span>{{ .Profile.PDSEndpoint }}</span> 32 + </div> 33 + </section> 34 + 35 + <!-- Default Hold Section --> 36 + <section class="settings-section"> 37 + <h2>Default Hold</h2> 38 + <p>Current: <strong>{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p> 39 + 40 + <form hx-post="/api/profile/default-hold" 41 + hx-target="#hold-status" 42 + hx-swap="innerHTML"> 43 + 44 + <div class="form-group"> 45 + <label for="hold-endpoint">Hold Endpoint:</label> 46 + <input type="text" 47 + id="hold-endpoint" 48 + name="hold_endpoint" 49 + value="{{ .Profile.DefaultHold }}" 50 + placeholder="https://hold.example.com" /> 51 + <small>Leave empty to use AppView default storage</small> 52 + </div> 53 + 54 + <button type="submit" class="btn-primary">Save</button> 55 + </form> 56 + 57 + <div id="hold-status"></div> 58 + </section> 59 + 60 + <!-- OAuth Session Section --> 61 + <section class="settings-section"> 62 + <h2>OAuth Session</h2> 63 + <div class="form-group"> 64 + <label>Logged in as:</label> 65 + <span>{{ .Profile.Handle }}</span> 66 + </div> 67 + <div class="form-group"> 68 + <label>Session expires:</label> 69 + <time datetime="{{ .SessionExpiry.Format "2006-01-02T15:04:05Z07:00" }}"> 70 + {{ .SessionExpiry.Format "2006-01-02 15:04:05 MST" }} 71 + </time> 72 + </div> 73 + <a href="/auth/oauth/login?return_to=/settings" class="btn-secondary">Re-authenticate</a> 74 + </section> 75 + </div> 76 + </main> 77 + 78 + <!-- Modal container for HTMX --> 79 + <div id="modal"></div> 80 + 81 + <script src="/static/js/app.js"></script> 82 + </body> 83 + </html> 84 + {{ end }}
+44
pkg/appview/templates/partials/push-list.html
··· 1 + {{ range .Pushes }} 2 + <div class="push-card"> 3 + <div class="push-header"> 4 + <a href="/?user={{ .Handle }}" class="push-user">{{ .Handle }}</a> 5 + <span class="push-separator">/</span> 6 + <span class="push-repo">{{ .Repository }}</span> 7 + <span class="push-separator">:</span> 8 + <span class="push-tag">{{ .Tag }}</span> 9 + </div> 10 + 11 + <div class="push-details"> 12 + <code class="digest">{{ truncateDigest .Digest 12 }}</code> 13 + <span class="separator">•</span> 14 + <span class="hold">{{ .HoldEndpoint }}</span> 15 + <span class="separator">•</span> 16 + <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 17 + {{ timeAgo .CreatedAt }} 18 + </time> 19 + </div> 20 + 21 + <div class="push-command"> 22 + <code class="pull-command">docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code> 23 + <button class="copy-btn" onclick="copyToClipboard('docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')"> 24 + 📋 Copy 25 + </button> 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + {{ if .HasMore }} 31 + <button class="load-more" 32 + hx-get="/api/recent-pushes?offset={{ .NextOffset }}" 33 + hx-target="#push-list" 34 + hx-swap="beforeend"> 35 + Load More 36 + </button> 37 + {{ end }} 38 + 39 + {{ if eq (len .Pushes) 0 }} 40 + <div class="empty-state"> 41 + <p>No pushes yet. Start using ATCR by pushing your first image!</p> 42 + <pre><code>docker push atcr.io/yourhandle/myapp:latest</code></pre> 43 + </div> 44 + {{ end }}
+57 -8
pkg/auth/oauth/server.go
··· 14 14 "atcr.io/pkg/auth/session" 15 15 ) 16 16 17 + // UISessionStore is the interface for UI session management 18 + type UISessionStore interface { 19 + Create(did, handle string, duration time.Duration) (string, error) 20 + } 21 + 17 22 // Server handles OAuth authorization for the AppView 18 23 type Server struct { 19 - storage *RefreshTokenStorage 20 - sessionManager *session.Manager 21 - resolver *atproto.Resolver 22 - refresher *Refresher 23 - baseURL string 24 - states map[string]*OAuthState 25 - statesMu sync.RWMutex 24 + storage *RefreshTokenStorage 25 + sessionManager *session.Manager 26 + resolver *atproto.Resolver 27 + refresher *Refresher 28 + uiSessionStore UISessionStore 29 + baseURL string 30 + states map[string]*OAuthState 31 + statesMu sync.RWMutex 26 32 } 27 33 28 34 // OAuthState tracks an in-progress OAuth flow ··· 51 57 // SetRefresher sets the refresher for invalidating access token cache 52 58 func (s *Server) SetRefresher(refresher *Refresher) { 53 59 s.refresher = refresher 60 + } 61 + 62 + // SetUISessionStore sets the UI session store for web login 63 + func (s *Server) SetUISessionStore(store UISessionStore) { 64 + s.uiSessionStore = store 54 65 } 55 66 56 67 // ServeAuthorize handles GET /auth/oauth/authorize ··· 159 170 return 160 171 } 161 172 162 - // Render success page with session token 173 + // Check if this is a UI login (has oauth_return_to cookie) 174 + if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { 175 + // Create UI session 176 + sessionID, err := s.uiSessionStore.Create(oauthState.DID, oauthState.Handle, 24*time.Hour) 177 + if err != nil { 178 + s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 179 + return 180 + } 181 + 182 + // Set UI session cookie 183 + http.SetCookie(w, &http.Cookie{ 184 + Name: "atcr_session", 185 + Value: sessionID, 186 + Path: "/", 187 + MaxAge: 86400, // 24 hours 188 + HttpOnly: true, 189 + Secure: true, 190 + SameSite: http.SameSiteLaxMode, 191 + }) 192 + 193 + // Clear the return_to cookie 194 + http.SetCookie(w, &http.Cookie{ 195 + Name: "oauth_return_to", 196 + Value: "", 197 + Path: "/", 198 + MaxAge: -1, 199 + HttpOnly: true, 200 + }) 201 + 202 + // Redirect to return URL 203 + returnTo := cookie.Value 204 + if returnTo == "" { 205 + returnTo = "/" 206 + } 207 + http.Redirect(w, r, returnTo, http.StatusFound) 208 + return 209 + } 210 + 211 + // Render success page with session token (for credential helper) 163 212 s.renderSuccess(w, sessionToken, oauthState.Handle) 164 213 } 165 214