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

Configure Feed

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

implement basic web ui

+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