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

This commit is contained in:
Evan Jarrett
2025-10-25 14:20:23 -05:00
parent c79d0ac3ab
commit d75a27557a
8 changed files with 73 additions and 253 deletions
+47 -217
View File
@@ -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
+1 -1
View File
@@ -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
+12 -17
View File
@@ -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)
+2 -3
View File
@@ -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
+5 -7
View File
@@ -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)
+3 -4
View File
@@ -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)
+2 -3
View File
@@ -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)
+1 -1
View File
@@ -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:"
}