A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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 }