From d75a27557ac3466fc12fec94ec420cff57c2e562 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sat, 25 Oct 2025 14:20:23 -0500 Subject: [PATCH] use chi for routes in appview. refactor routes outside of serve.go --- cmd/appview/serve.go | 264 +++++------------------------ go.mod | 2 +- pkg/appview/handlers/api.go | 29 ++-- pkg/appview/handlers/device.go | 5 +- pkg/appview/handlers/images.go | 12 +- pkg/appview/handlers/repository.go | 7 +- pkg/appview/handlers/user.go | 5 +- pkg/atproto/lexicon.go | 2 +- 8 files changed, 73 insertions(+), 253 deletions(-) diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 97d13a8..6bf6c14 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -34,7 +34,9 @@ import ( "atcr.io/pkg/appview/holdhealth" "atcr.io/pkg/appview/jetstream" "atcr.io/pkg/appview/readme" - "github.com/gorilla/mux" + "atcr.io/pkg/appview/routes" + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" ) var serveCmd = &cobra.Command{ @@ -165,8 +167,39 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Initialize Jetstream workers (background services before HTTP routes) initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) - // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache - uiTemplates, uiRouter := initializeUIRoutes(cfg.UI.Enabled, uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, healthChecker, readmeCache) + // Create main chi router + mainRouter := chi.NewRouter() + + // Add core middleware + mainRouter.Use(chimiddleware.Logger) + mainRouter.Use(chimiddleware.Recoverer) + mainRouter.Use(chimiddleware.GetHead) // Automatically handle HEAD requests for GET routes + mainRouter.Use(routes.CORSMiddleware()) + + // Load templates if UI is enabled + var uiTemplates *template.Template + if cfg.UI.Enabled { + var err error + uiTemplates, err = appview.Templates() + if err != nil { + slog.Warn("Failed to load UI templates", "error", err) + } else { + // Register UI routes with dependencies + routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{ + Database: uiDatabase, + ReadOnlyDB: uiReadOnlyDB, + SessionStore: uiSessionStore, + OAuthApp: oauthApp, + OAuthStore: oauthStore, + Refresher: refresher, + BaseURL: baseURL, + DeviceStore: deviceStore, + HealthChecker: healthChecker, + ReadmeCache: readmeCache, + Templates: uiTemplates, + }) + } + } // Create OAuth server oauthServer := oauth.NewServer(oauthApp) @@ -307,29 +340,23 @@ func serveRegistry(cmd *cobra.Command, args []string) error { ctx := context.Background() app := handlers.NewApp(ctx, cfg.Distribution) - // Create main HTTP mux - mux := http.NewServeMux() - // Mount registry at /v2/ - mux.Handle("/v2/", app) + mainRouter.Handle("/v2/*", app) - // Mount UI routes if enabled - if uiSessionStore != nil && uiTemplates != nil && uiRouter != nil { + // Mount static files if UI is enabled + if uiSessionStore != nil && uiTemplates != nil { // Mount static files - mux.Handle("/static/", http.StripPrefix("/static/", appview.StaticHandler())) - - // Mount UI routes directly at root level - mux.Handle("/", uiRouter) + mainRouter.Handle("/static/*", http.StripPrefix("/static/", appview.StaticHandler())) slog.Info("UI enabled", "home", "/", "settings", "/settings") } // Mount OAuth endpoints - mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize) - mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback) + mainRouter.Get("/auth/oauth/authorize", oauthServer.ServeAuthorize) + mainRouter.Get("/auth/oauth/callback", oauthServer.ServeCallback) // OAuth client metadata endpoint - mux.HandleFunc("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { + mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { config := oauthApp.GetConfig() metadata := config.ClientMetadata() @@ -366,14 +393,14 @@ func serveRegistry(cmd *cobra.Command, args []string) error { return nil // All errors are non-fatal }) - tokenHandler.RegisterRoutes(mux) + mainRouter.Post("/auth/token", tokenHandler.ServeHTTP) // Device authorization endpoints (public) - mux.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{ + mainRouter.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{ Store: deviceStore, AppViewBaseURL: baseURL, }) - mux.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{ + mainRouter.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{ Store: deviceStore, }) @@ -389,7 +416,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Create HTTP server server := &http.Server{ Addr: cfg.Server.Addr, - Handler: mux, + Handler: mainRouter, } // Handle graceful shutdown @@ -439,203 +466,6 @@ func createTokenIssuer(cfg *appview.Config) (*token.Issuer, error) { ) } -// initializeUIRoutes initializes the web UI routes -// uiEnabled: whether UI is enabled (from Config.UI.Enabled) -// database: read-write connection for auth and writes -// readOnlyDB: read-only connection for public queries (search, user pages, etc.) -// healthChecker: hold endpoint health checker -// readmeCache: README cache for repository pages -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) { - // Check if UI is enabled - if !uiEnabled { - return nil, nil - } - - // Load templates - templates, err := appview.Templates() - if err != nil { - slog.Warn("Failed to load UI templates", "error", err) - return nil, nil - } - - // Create router - router := mux.NewRouter() - - // OAuth login routes (public) - router.Handle("/auth/oauth/login", &uihandlers.LoginHandler{ - Templates: templates, - }).Methods("GET") - - router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{}).Methods("POST") - - // Public routes (with optional auth for navbar) - // SECURITY: Public pages use read-only DB - router.Handle("/", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.HomeHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }, - )).Methods("GET") - - router.Handle("/api/recent-pushes", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.RecentPushesHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - HealthChecker: healthChecker, - }, - )).Methods("GET") - - // SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables - router.Handle("/search", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.SearchHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }, - )).Methods("GET") - - router.Handle("/api/search-results", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.SearchResultsHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }, - )).Methods("GET") - - // Install page (public) - router.Handle("/install", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.InstallHandler{ - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }, - )).Methods("GET") - - // API route for repository stats (public, read-only) - router.Handle("/api/stats/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.GetStatsHandler{ - DB: readOnlyDB, - Directory: oauthApp.Directory(), - }, - )).Methods("GET") - - // API routes for stars (require authentication) - router.Handle("/api/stars/{handle}/{repository}", middleware.RequireAuth(sessionStore, database)( - &uihandlers.StarRepositoryHandler{ - DB: database, // Needs write access - Directory: oauthApp.Directory(), - Refresher: refresher, - }, - )).Methods("POST") - - router.Handle("/api/stars/{handle}/{repository}", middleware.RequireAuth(sessionStore, database)( - &uihandlers.UnstarRepositoryHandler{ - DB: database, // Needs write access - Directory: oauthApp.Directory(), - Refresher: refresher, - }, - )).Methods("DELETE") - - router.Handle("/api/stars/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.CheckStarHandler{ - DB: readOnlyDB, // Read-only check - Directory: oauthApp.Directory(), - Refresher: refresher, - }, - )).Methods("GET") - - // Manifest detail API endpoint - router.Handle("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.ManifestDetailHandler{ - DB: readOnlyDB, - Directory: oauthApp.Directory(), - }, - )).Methods("GET") - - // Manifest health check API endpoint (HTMX polling) - router.Handle("/api/manifest-health", &uihandlers.ManifestHealthHandler{ - HealthChecker: healthChecker, - }).Methods("GET") - - router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.UserPageHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }, - )).Methods("GET") - - router.Handle("/r/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)( - &uihandlers.RepositoryPageHandler{ - DB: readOnlyDB, - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - Directory: oauthApp.Directory(), - Refresher: refresher, - HealthChecker: healthChecker, - ReadmeCache: readmeCache, - }, - )).Methods("GET") - - // Authenticated routes - authRouter := router.NewRoute().Subrouter() - authRouter.Use(middleware.RequireAuth(sessionStore, database)) - - authRouter.Handle("/settings", &uihandlers.SettingsHandler{ - Templates: templates, - Refresher: refresher, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }).Methods("GET") - - authRouter.Handle("/api/profile/default-hold", &uihandlers.UpdateDefaultHoldHandler{ - Refresher: refresher, - }).Methods("POST") - - authRouter.Handle("/api/images/{repository}/tags/{tag}", &uihandlers.DeleteTagHandler{ - DB: database, - Refresher: refresher, - }).Methods("DELETE") - - authRouter.Handle("/api/images/{repository}/manifests/{digest}", &uihandlers.DeleteManifestHandler{ - DB: database, - Refresher: refresher, - }).Methods("DELETE") - - // Device approval page (authenticated) - authRouter.Handle("/device", &uihandlers.DeviceApprovalPageHandler{ - Store: deviceStore, - SessionStore: sessionStore, - }).Methods("GET") - - authRouter.Handle("/device/approve", &uihandlers.DeviceApproveHandler{ - Store: deviceStore, - SessionStore: sessionStore, - }).Methods("POST") - - // Device management routes - authRouter.Handle("/api/devices", &uihandlers.ListDevicesHandler{ - Store: deviceStore, - SessionStore: sessionStore, - }).Methods("GET") - - authRouter.Handle("/api/devices/{id}", &uihandlers.RevokeDeviceHandler{ - Store: deviceStore, - SessionStore: sessionStore, - }).Methods("DELETE") - - // Logout endpoint (supports both GET and POST) - // Properly revokes OAuth tokens on PDS side before clearing local session - router.Handle("/auth/logout", &uihandlers.LogoutHandler{ - OAuthApp: oauthApp, - Refresher: refresher, - SessionStore: sessionStore, - OAuthStore: oauthStore, - }).Methods("GET", "POST") - - return templates, router -} - // initializeJetstream initializes the Jetstream workers for real-time events and backfill func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) { // Start Jetstream worker diff --git a/go.mod b/go.mod index acb6e27..83dd232 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 - github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/ipfs/go-block-format v0.2.0 github.com/ipfs/go-cid v0.4.1 @@ -57,6 +56,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/pkg/appview/handlers/api.go b/pkg/appview/handlers/api.go index 16611e2..7d81f30 100644 --- a/pkg/appview/handlers/api.go +++ b/pkg/appview/handlers/api.go @@ -15,7 +15,7 @@ import ( "atcr.io/pkg/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/gorilla/mux" + "github.com/go-chi/chi/v5" ) // StarRepositoryHandler handles starring a repository @@ -34,9 +34,8 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } // Extract parameters - vars := mux.Vars(r) - handle := vars["handle"] - repository := vars["repository"] + handle := chi.URLParam(r, "handle") + repository := chi.URLParam(r, "repository") // Resolve owner's handle to DID ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) @@ -93,9 +92,8 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque } // Extract parameters - vars := mux.Vars(r) - handle := vars["handle"] - repository := vars["repository"] + handle := chi.URLParam(r, "handle") + repository := chi.URLParam(r, "repository") // Resolve owner's handle to DID ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) @@ -155,9 +153,8 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Extract parameters - vars := mux.Vars(r) - handle := vars["handle"] - repository := vars["repository"] + handle := chi.URLParam(r, "handle") + repository := chi.URLParam(r, "repository") // Resolve owner's handle to DID ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) @@ -200,9 +197,8 @@ type GetStatsHandler struct { func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Extract parameters - vars := mux.Vars(r) - handle := vars["handle"] - repository := vars["repository"] + handle := chi.URLParam(r, "handle") + repository := chi.URLParam(r, "repository") // Resolve owner's handle to DID ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) @@ -231,10 +227,9 @@ type ManifestDetailHandler struct { func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Extract parameters - vars := mux.Vars(r) - handle := vars["handle"] - repository := vars["repository"] - digest := vars["digest"] + handle := chi.URLParam(r, "handle") + repository := chi.URLParam(r, "repository") + digest := chi.URLParam(r, "digest") // Resolve owner's handle to DID ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) diff --git a/pkg/appview/handlers/device.go b/pkg/appview/handlers/device.go index 208b333..efce081 100644 --- a/pkg/appview/handlers/device.go +++ b/pkg/appview/handlers/device.go @@ -9,7 +9,7 @@ import ( "net/url" "strings" - "github.com/gorilla/mux" + "github.com/go-chi/chi/v5" "atcr.io/pkg/appview/db" ) @@ -338,8 +338,7 @@ func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } // Get device ID from URL - vars := mux.Vars(r) - deviceID := vars["id"] + deviceID := chi.URLParam(r, "id") if deviceID == "" { http.Error(w, "device ID required", http.StatusBadRequest) return diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index 7ad1115..2c8d0d5 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -10,7 +10,7 @@ import ( "atcr.io/pkg/appview/middleware" "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" - "github.com/gorilla/mux" + "github.com/go-chi/chi/v5" ) // DeleteTagHandler handles deleting a tag @@ -26,9 +26,8 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - vars := mux.Vars(r) - repo := vars["repository"] - tag := vars["tag"] + repo := chi.URLParam(r, "repository") + tag := chi.URLParam(r, "tag") // Get OAuth session for the authenticated user session, err := h.Refresher.GetSession(r.Context(), user.DID) @@ -74,9 +73,8 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } - vars := mux.Vars(r) - repo := vars["repository"] - digest := vars["digest"] + repo := chi.URLParam(r, "repository") + digest := chi.URLParam(r, "digest") // Check if manifest is tagged tagged, err := db.IsManifestTagged(h.DB, user.DID, repo, digest) diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 8a30bf1..4383660 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -16,7 +16,7 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" - "github.com/gorilla/mux" + "github.com/go-chi/chi/v5" ) // RepositoryPageHandler handles the public repository page @@ -31,9 +31,8 @@ type RepositoryPageHandler struct { } func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - handle := vars["handle"] - repository := vars["repository"] + handle := chi.URLParam(r, "handle") + repository := chi.URLParam(r, "repository") // Look up user by handle owner, err := db.GetUserByHandle(h.DB, handle) diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index 328c4fb..248ac46 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -6,7 +6,7 @@ import ( "net/http" "atcr.io/pkg/appview/db" - "github.com/gorilla/mux" + "github.com/go-chi/chi/v5" ) // UserPageHandler handles the public user page showing all images for a user @@ -17,8 +17,7 @@ type UserPageHandler struct { } func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - handle := vars["handle"] + handle := chi.URLParam(r, "handle") // Look up user by handle viewedUser, err := db.GetUserByHandle(h.DB, handle) diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index 9fcb64f..81a1e8e 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -433,7 +433,7 @@ func ResolveHoldDIDFromURL(holdURL string) string { return "did:web:" + hostname } -// isDID checks if a string is a DID (starts with "did:") +// IsDID checks if a string is a DID (starts with "did:") func IsDID(s string) bool { return len(s) > 4 && s[:4] == "did:" }