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

Configure Feed

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

use chi for routes in appview. refactor routes outside of serve.go

+73 -253
+47 -217
cmd/appview/serve.go
··· 34 34 "atcr.io/pkg/appview/holdhealth" 35 35 "atcr.io/pkg/appview/jetstream" 36 36 "atcr.io/pkg/appview/readme" 37 - "github.com/gorilla/mux" 37 + "atcr.io/pkg/appview/routes" 38 + "github.com/go-chi/chi/v5" 39 + chimiddleware "github.com/go-chi/chi/v5/middleware" 38 40 ) 39 41 40 42 var serveCmd = &cobra.Command{ ··· 165 167 // Initialize Jetstream workers (background services before HTTP routes) 166 168 initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) 167 169 168 - // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache 169 - uiTemplates, uiRouter := initializeUIRoutes(cfg.UI.Enabled, uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, healthChecker, readmeCache) 170 + // Create main chi router 171 + mainRouter := chi.NewRouter() 172 + 173 + // Add core middleware 174 + mainRouter.Use(chimiddleware.Logger) 175 + mainRouter.Use(chimiddleware.Recoverer) 176 + mainRouter.Use(chimiddleware.GetHead) // Automatically handle HEAD requests for GET routes 177 + mainRouter.Use(routes.CORSMiddleware()) 178 + 179 + // Load templates if UI is enabled 180 + var uiTemplates *template.Template 181 + if cfg.UI.Enabled { 182 + var err error 183 + uiTemplates, err = appview.Templates() 184 + if err != nil { 185 + slog.Warn("Failed to load UI templates", "error", err) 186 + } else { 187 + // Register UI routes with dependencies 188 + routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{ 189 + Database: uiDatabase, 190 + ReadOnlyDB: uiReadOnlyDB, 191 + SessionStore: uiSessionStore, 192 + OAuthApp: oauthApp, 193 + OAuthStore: oauthStore, 194 + Refresher: refresher, 195 + BaseURL: baseURL, 196 + DeviceStore: deviceStore, 197 + HealthChecker: healthChecker, 198 + ReadmeCache: readmeCache, 199 + Templates: uiTemplates, 200 + }) 201 + } 202 + } 170 203 171 204 // Create OAuth server 172 205 oauthServer := oauth.NewServer(oauthApp) ··· 307 340 ctx := context.Background() 308 341 app := handlers.NewApp(ctx, cfg.Distribution) 309 342 310 - // Create main HTTP mux 311 - mux := http.NewServeMux() 312 - 313 343 // Mount registry at /v2/ 314 - mux.Handle("/v2/", app) 344 + mainRouter.Handle("/v2/*", app) 315 345 316 - // Mount UI routes if enabled 317 - if uiSessionStore != nil && uiTemplates != nil && uiRouter != nil { 346 + // Mount static files if UI is enabled 347 + if uiSessionStore != nil && uiTemplates != nil { 318 348 // Mount static files 319 - mux.Handle("/static/", http.StripPrefix("/static/", appview.StaticHandler())) 320 - 321 - // Mount UI routes directly at root level 322 - mux.Handle("/", uiRouter) 349 + mainRouter.Handle("/static/*", http.StripPrefix("/static/", appview.StaticHandler())) 323 350 324 351 slog.Info("UI enabled", "home", "/", "settings", "/settings") 325 352 } 326 353 327 354 // Mount OAuth endpoints 328 - mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize) 329 - mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback) 355 + mainRouter.Get("/auth/oauth/authorize", oauthServer.ServeAuthorize) 356 + mainRouter.Get("/auth/oauth/callback", oauthServer.ServeCallback) 330 357 331 358 // OAuth client metadata endpoint 332 - mux.HandleFunc("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 359 + mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 333 360 config := oauthApp.GetConfig() 334 361 metadata := config.ClientMetadata() 335 362 ··· 366 393 return nil // All errors are non-fatal 367 394 }) 368 395 369 - tokenHandler.RegisterRoutes(mux) 396 + mainRouter.Post("/auth/token", tokenHandler.ServeHTTP) 370 397 371 398 // Device authorization endpoints (public) 372 - mux.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{ 399 + mainRouter.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{ 373 400 Store: deviceStore, 374 401 AppViewBaseURL: baseURL, 375 402 }) 376 - mux.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{ 403 + mainRouter.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{ 377 404 Store: deviceStore, 378 405 }) 379 406 ··· 389 416 // Create HTTP server 390 417 server := &http.Server{ 391 418 Addr: cfg.Server.Addr, 392 - Handler: mux, 419 + Handler: mainRouter, 393 420 } 394 421 395 422 // Handle graceful shutdown ··· 437 464 cfg.Auth.ServiceName, // service 438 465 cfg.Auth.TokenExpiration, 439 466 ) 440 - } 441 - 442 - // initializeUIRoutes initializes the web UI routes 443 - // uiEnabled: whether UI is enabled (from Config.UI.Enabled) 444 - // database: read-write connection for auth and writes 445 - // readOnlyDB: read-only connection for public queries (search, user pages, etc.) 446 - // healthChecker: hold endpoint health checker 447 - // readmeCache: README cache for repository pages 448 - func initializeUIRoutes(uiEnabled bool, database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, oauthStore *db.OAuthStore, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) { 449 - // Check if UI is enabled 450 - if !uiEnabled { 451 - return nil, nil 452 - } 453 - 454 - // Load templates 455 - templates, err := appview.Templates() 456 - if err != nil { 457 - slog.Warn("Failed to load UI templates", "error", err) 458 - return nil, nil 459 - } 460 - 461 - // Create router 462 - router := mux.NewRouter() 463 - 464 - // OAuth login routes (public) 465 - router.Handle("/auth/oauth/login", &uihandlers.LoginHandler{ 466 - Templates: templates, 467 - }).Methods("GET") 468 - 469 - router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{}).Methods("POST") 470 - 471 - // Public routes (with optional auth for navbar) 472 - // SECURITY: Public pages use read-only DB 473 - router.Handle("/", middleware.OptionalAuth(sessionStore, database)( 474 - &uihandlers.HomeHandler{ 475 - DB: readOnlyDB, 476 - Templates: templates, 477 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 478 - }, 479 - )).Methods("GET") 480 - 481 - router.Handle("/api/recent-pushes", middleware.OptionalAuth(sessionStore, database)( 482 - &uihandlers.RecentPushesHandler{ 483 - DB: readOnlyDB, 484 - Templates: templates, 485 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 486 - HealthChecker: healthChecker, 487 - }, 488 - )).Methods("GET") 489 - 490 - // SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables 491 - router.Handle("/search", middleware.OptionalAuth(sessionStore, database)( 492 - &uihandlers.SearchHandler{ 493 - DB: readOnlyDB, 494 - Templates: templates, 495 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 496 - }, 497 - )).Methods("GET") 498 - 499 - router.Handle("/api/search-results", middleware.OptionalAuth(sessionStore, database)( 500 - &uihandlers.SearchResultsHandler{ 501 - DB: readOnlyDB, 502 - Templates: templates, 503 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 504 - }, 505 - )).Methods("GET") 506 - 507 - // Install page (public) 508 - router.Handle("/install", middleware.OptionalAuth(sessionStore, database)( 509 - &uihandlers.InstallHandler{ 510 - Templates: templates, 511 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 512 - }, 513 - )).Methods("GET") 514 - 515 - // API route for repository stats (public, read-only) 516 - router.Handle("/api/stats/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( 517 - &uihandlers.GetStatsHandler{ 518 - DB: readOnlyDB, 519 - Directory: oauthApp.Directory(), 520 - }, 521 - )).Methods("GET") 522 - 523 - // API routes for stars (require authentication) 524 - router.Handle("/api/stars/{handle}/{repository}", middleware.RequireAuth(sessionStore, database)( 525 - &uihandlers.StarRepositoryHandler{ 526 - DB: database, // Needs write access 527 - Directory: oauthApp.Directory(), 528 - Refresher: refresher, 529 - }, 530 - )).Methods("POST") 531 - 532 - router.Handle("/api/stars/{handle}/{repository}", middleware.RequireAuth(sessionStore, database)( 533 - &uihandlers.UnstarRepositoryHandler{ 534 - DB: database, // Needs write access 535 - Directory: oauthApp.Directory(), 536 - Refresher: refresher, 537 - }, 538 - )).Methods("DELETE") 539 - 540 - router.Handle("/api/stars/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( 541 - &uihandlers.CheckStarHandler{ 542 - DB: readOnlyDB, // Read-only check 543 - Directory: oauthApp.Directory(), 544 - Refresher: refresher, 545 - }, 546 - )).Methods("GET") 547 - 548 - // Manifest detail API endpoint 549 - router.Handle("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(sessionStore, database)( 550 - &uihandlers.ManifestDetailHandler{ 551 - DB: readOnlyDB, 552 - Directory: oauthApp.Directory(), 553 - }, 554 - )).Methods("GET") 555 - 556 - // Manifest health check API endpoint (HTMX polling) 557 - router.Handle("/api/manifest-health", &uihandlers.ManifestHealthHandler{ 558 - HealthChecker: healthChecker, 559 - }).Methods("GET") 560 - 561 - router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)( 562 - &uihandlers.UserPageHandler{ 563 - DB: readOnlyDB, 564 - Templates: templates, 565 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 566 - }, 567 - )).Methods("GET") 568 - 569 - router.Handle("/r/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( 570 - &uihandlers.RepositoryPageHandler{ 571 - DB: readOnlyDB, 572 - Templates: templates, 573 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 574 - Directory: oauthApp.Directory(), 575 - Refresher: refresher, 576 - HealthChecker: healthChecker, 577 - ReadmeCache: readmeCache, 578 - }, 579 - )).Methods("GET") 580 - 581 - // Authenticated routes 582 - authRouter := router.NewRoute().Subrouter() 583 - authRouter.Use(middleware.RequireAuth(sessionStore, database)) 584 - 585 - authRouter.Handle("/settings", &uihandlers.SettingsHandler{ 586 - Templates: templates, 587 - Refresher: refresher, 588 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 589 - }).Methods("GET") 590 - 591 - authRouter.Handle("/api/profile/default-hold", &uihandlers.UpdateDefaultHoldHandler{ 592 - Refresher: refresher, 593 - }).Methods("POST") 594 - 595 - authRouter.Handle("/api/images/{repository}/tags/{tag}", &uihandlers.DeleteTagHandler{ 596 - DB: database, 597 - Refresher: refresher, 598 - }).Methods("DELETE") 599 - 600 - authRouter.Handle("/api/images/{repository}/manifests/{digest}", &uihandlers.DeleteManifestHandler{ 601 - DB: database, 602 - Refresher: refresher, 603 - }).Methods("DELETE") 604 - 605 - // Device approval page (authenticated) 606 - authRouter.Handle("/device", &uihandlers.DeviceApprovalPageHandler{ 607 - Store: deviceStore, 608 - SessionStore: sessionStore, 609 - }).Methods("GET") 610 - 611 - authRouter.Handle("/device/approve", &uihandlers.DeviceApproveHandler{ 612 - Store: deviceStore, 613 - SessionStore: sessionStore, 614 - }).Methods("POST") 615 - 616 - // Device management routes 617 - authRouter.Handle("/api/devices", &uihandlers.ListDevicesHandler{ 618 - Store: deviceStore, 619 - SessionStore: sessionStore, 620 - }).Methods("GET") 621 - 622 - authRouter.Handle("/api/devices/{id}", &uihandlers.RevokeDeviceHandler{ 623 - Store: deviceStore, 624 - SessionStore: sessionStore, 625 - }).Methods("DELETE") 626 - 627 - // Logout endpoint (supports both GET and POST) 628 - // Properly revokes OAuth tokens on PDS side before clearing local session 629 - router.Handle("/auth/logout", &uihandlers.LogoutHandler{ 630 - OAuthApp: oauthApp, 631 - Refresher: refresher, 632 - SessionStore: sessionStore, 633 - OAuthStore: oauthStore, 634 - }).Methods("GET", "POST") 635 - 636 - return templates, router 637 467 } 638 468 639 469 // initializeJetstream initializes the Jetstream workers for real-time events and backfill
+1 -1
go.mod
··· 10 10 github.com/go-chi/chi/v5 v5.2.3 11 11 github.com/golang-jwt/jwt/v5 v5.2.2 12 12 github.com/google/uuid v1.6.0 13 - github.com/gorilla/mux v1.8.1 14 13 github.com/gorilla/websocket v1.5.3 15 14 github.com/ipfs/go-block-format v0.2.0 16 15 github.com/ipfs/go-cid v0.4.1 ··· 57 56 github.com/google/go-querystring v1.1.0 // indirect 58 57 github.com/gorilla/css v1.0.1 // indirect 59 58 github.com/gorilla/handlers v1.5.2 // indirect 59 + github.com/gorilla/mux v1.8.1 // indirect 60 60 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect 61 61 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 62 62 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+12 -17
pkg/appview/handlers/api.go
··· 15 15 "atcr.io/pkg/auth/oauth" 16 16 "github.com/bluesky-social/indigo/atproto/identity" 17 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 - "github.com/gorilla/mux" 18 + "github.com/go-chi/chi/v5" 19 19 ) 20 20 21 21 // StarRepositoryHandler handles starring a repository ··· 34 34 } 35 35 36 36 // Extract parameters 37 - vars := mux.Vars(r) 38 - handle := vars["handle"] 39 - repository := vars["repository"] 37 + handle := chi.URLParam(r, "handle") 38 + repository := chi.URLParam(r, "repository") 40 39 41 40 // Resolve owner's handle to DID 42 41 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) ··· 93 92 } 94 93 95 94 // Extract parameters 96 - vars := mux.Vars(r) 97 - handle := vars["handle"] 98 - repository := vars["repository"] 95 + handle := chi.URLParam(r, "handle") 96 + repository := chi.URLParam(r, "repository") 99 97 100 98 // Resolve owner's handle to DID 101 99 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) ··· 155 153 } 156 154 157 155 // Extract parameters 158 - vars := mux.Vars(r) 159 - handle := vars["handle"] 160 - repository := vars["repository"] 156 + handle := chi.URLParam(r, "handle") 157 + repository := chi.URLParam(r, "repository") 161 158 162 159 // Resolve owner's handle to DID 163 160 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) ··· 200 197 201 198 func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 202 199 // Extract parameters 203 - vars := mux.Vars(r) 204 - handle := vars["handle"] 205 - repository := vars["repository"] 200 + handle := chi.URLParam(r, "handle") 201 + repository := chi.URLParam(r, "repository") 206 202 207 203 // Resolve owner's handle to DID 208 204 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) ··· 231 227 232 228 func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 233 229 // Extract parameters 234 - vars := mux.Vars(r) 235 - handle := vars["handle"] 236 - repository := vars["repository"] 237 - digest := vars["digest"] 230 + handle := chi.URLParam(r, "handle") 231 + repository := chi.URLParam(r, "repository") 232 + digest := chi.URLParam(r, "digest") 238 233 239 234 // Resolve owner's handle to DID 240 235 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle)
+2 -3
pkg/appview/handlers/device.go
··· 9 9 "net/url" 10 10 "strings" 11 11 12 - "github.com/gorilla/mux" 12 + "github.com/go-chi/chi/v5" 13 13 14 14 "atcr.io/pkg/appview/db" 15 15 ) ··· 338 338 } 339 339 340 340 // Get device ID from URL 341 - vars := mux.Vars(r) 342 - deviceID := vars["id"] 341 + deviceID := chi.URLParam(r, "id") 343 342 if deviceID == "" { 344 343 http.Error(w, "device ID required", http.StatusBadRequest) 345 344 return
+5 -7
pkg/appview/handlers/images.go
··· 10 10 "atcr.io/pkg/appview/middleware" 11 11 "atcr.io/pkg/atproto" 12 12 "atcr.io/pkg/auth/oauth" 13 - "github.com/gorilla/mux" 13 + "github.com/go-chi/chi/v5" 14 14 ) 15 15 16 16 // DeleteTagHandler handles deleting a tag ··· 26 26 return 27 27 } 28 28 29 - vars := mux.Vars(r) 30 - repo := vars["repository"] 31 - tag := vars["tag"] 29 + repo := chi.URLParam(r, "repository") 30 + tag := chi.URLParam(r, "tag") 32 31 33 32 // Get OAuth session for the authenticated user 34 33 session, err := h.Refresher.GetSession(r.Context(), user.DID) ··· 74 73 return 75 74 } 76 75 77 - vars := mux.Vars(r) 78 - repo := vars["repository"] 79 - digest := vars["digest"] 76 + repo := chi.URLParam(r, "repository") 77 + digest := chi.URLParam(r, "digest") 80 78 81 79 // Check if manifest is tagged 82 80 tagged, err := db.IsManifestTagged(h.DB, user.DID, repo, digest)
+3 -4
pkg/appview/handlers/repository.go
··· 16 16 "atcr.io/pkg/atproto" 17 17 "atcr.io/pkg/auth/oauth" 18 18 "github.com/bluesky-social/indigo/atproto/identity" 19 - "github.com/gorilla/mux" 19 + "github.com/go-chi/chi/v5" 20 20 ) 21 21 22 22 // RepositoryPageHandler handles the public repository page ··· 31 31 } 32 32 33 33 func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 34 - vars := mux.Vars(r) 35 - handle := vars["handle"] 36 - repository := vars["repository"] 34 + handle := chi.URLParam(r, "handle") 35 + repository := chi.URLParam(r, "repository") 37 36 38 37 // Look up user by handle 39 38 owner, err := db.GetUserByHandle(h.DB, handle)
+2 -3
pkg/appview/handlers/user.go
··· 6 6 "net/http" 7 7 8 8 "atcr.io/pkg/appview/db" 9 - "github.com/gorilla/mux" 9 + "github.com/go-chi/chi/v5" 10 10 ) 11 11 12 12 // UserPageHandler handles the public user page showing all images for a user ··· 17 17 } 18 18 19 19 func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 - vars := mux.Vars(r) 21 - handle := vars["handle"] 20 + handle := chi.URLParam(r, "handle") 22 21 23 22 // Look up user by handle 24 23 viewedUser, err := db.GetUserByHandle(h.DB, handle)
+1 -1
pkg/atproto/lexicon.go
··· 433 433 return "did:web:" + hostname 434 434 } 435 435 436 - // isDID checks if a string is a DID (starts with "did:") 436 + // IsDID checks if a string is a DID (starts with "did:") 437 437 func IsDID(s string) bool { 438 438 return len(s) > 4 && s[:4] == "did:" 439 439 }