mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
703 lines
25 KiB
Go
703 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
"github.com/distribution/distribution/v3/registry"
|
|
"github.com/distribution/distribution/v3/registry/handlers"
|
|
"github.com/spf13/cobra"
|
|
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/oauth"
|
|
"atcr.io/pkg/auth/token"
|
|
"atcr.io/pkg/logging"
|
|
|
|
// UI components
|
|
"atcr.io/pkg/appview"
|
|
"atcr.io/pkg/appview/db"
|
|
uihandlers "atcr.io/pkg/appview/handlers"
|
|
"atcr.io/pkg/appview/holdhealth"
|
|
"atcr.io/pkg/appview/jetstream"
|
|
"atcr.io/pkg/appview/readme"
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
var serveCmd = &cobra.Command{
|
|
Use: "serve",
|
|
Short: "Start the ATCR registry server",
|
|
Long: `Start the ATCR registry server with authentication endpoints.
|
|
|
|
Configuration is loaded from environment variables.
|
|
See .env.appview.example for available environment variables.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: serveRegistry,
|
|
}
|
|
|
|
func init() {
|
|
// Replace the default serve command with our custom one
|
|
for i, cmd := range registry.RootCmd.Commands() {
|
|
if cmd.Name() == "serve" {
|
|
registry.RootCmd.Commands()[i] = serveCmd
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveRegistry(cmd *cobra.Command, args []string) error {
|
|
// Load configuration from environment variables
|
|
cfg, err := appview.LoadConfigFromEnv()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config from environment: %w", err)
|
|
}
|
|
|
|
// Initialize structured logging
|
|
logging.InitLogger(cfg.LogLevel)
|
|
|
|
slog.Info("Configuration loaded successfully from environment")
|
|
|
|
// Initialize UI database first (required for all stores)
|
|
slog.Info("Initializing UI database", "path", cfg.UI.DatabasePath)
|
|
uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(cfg.UI.Enabled, cfg.UI.DatabasePath)
|
|
if uiDatabase == nil {
|
|
return fmt.Errorf("failed to initialize UI database - required for session storage")
|
|
}
|
|
|
|
// Initialize hold health checker
|
|
slog.Info("Initializing hold health checker", "cache_ttl", cfg.Health.CacheTTL)
|
|
healthChecker := holdhealth.NewChecker(cfg.Health.CacheTTL)
|
|
|
|
// Initialize README cache
|
|
slog.Info("Initializing README cache", "cache_ttl", cfg.Health.ReadmeCacheTTL)
|
|
readmeCache := readme.NewCache(uiDatabase, cfg.Health.ReadmeCacheTTL)
|
|
|
|
// Start background health check worker
|
|
startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose)
|
|
dbAdapter := holdhealth.NewDBAdapter(uiDatabase)
|
|
healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, cfg.Health.CheckInterval, startupDelay)
|
|
|
|
// Create context for worker lifecycle management
|
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
|
defer workerCancel() // Ensure context is cancelled on all exit paths
|
|
healthWorker.Start(workerCtx)
|
|
slog.Info("Hold health worker started", "startup_delay", startupDelay, "refresh_interval", cfg.Health.CheckInterval, "cache_ttl", cfg.Health.CacheTTL)
|
|
|
|
// Initialize OAuth components
|
|
slog.Info("Initializing OAuth components")
|
|
|
|
// Create OAuth session storage (SQLite-backed)
|
|
oauthStore := db.NewOAuthStore(uiDatabase)
|
|
slog.Info("Using SQLite for OAuth session storage")
|
|
|
|
// Create device store (SQLite-backed)
|
|
deviceStore := db.NewDeviceStore(uiDatabase)
|
|
slog.Info("Using SQLite for device storage")
|
|
|
|
// Get base URL and default hold DID from config
|
|
baseURL := cfg.Server.BaseURL
|
|
defaultHoldDID := cfg.Server.DefaultHoldDID
|
|
testMode := cfg.Server.TestMode
|
|
|
|
slog.Debug("Base URL for OAuth", "base_url", baseURL)
|
|
if testMode {
|
|
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
|
|
}
|
|
|
|
// Create OAuth app (indigo client)
|
|
oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, testMode)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create OAuth app: %w", err)
|
|
}
|
|
if testMode {
|
|
slog.Info("Using OAuth scopes with transition:generic (test mode)")
|
|
} else {
|
|
slog.Info("Using OAuth scopes with RPC scope (production mode)")
|
|
}
|
|
|
|
// Invalidate sessions with mismatched scopes on startup
|
|
// This ensures all users have the latest required scopes after deployment
|
|
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID, testMode)
|
|
invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
|
|
if err != nil {
|
|
slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err)
|
|
} else if invalidatedCount > 0 {
|
|
slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount)
|
|
}
|
|
|
|
// Create oauth token refresher
|
|
refresher := oauth.NewRefresher(oauthApp)
|
|
|
|
// Wire up UI session store to refresher so it can invalidate UI sessions on OAuth failures
|
|
if uiSessionStore != nil {
|
|
refresher.SetUISessionStore(uiSessionStore)
|
|
}
|
|
|
|
// Set global refresher for middleware
|
|
middleware.SetGlobalRefresher(refresher)
|
|
|
|
// Set global database for pull/push metrics tracking
|
|
metricsDB := db.NewMetricsDB(uiDatabase)
|
|
middleware.SetGlobalDatabase(metricsDB)
|
|
|
|
// Create RemoteHoldAuthorizer for hold authorization with caching
|
|
holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode)
|
|
middleware.SetGlobalAuthorizer(holdAuthorizer)
|
|
slog.Info("Hold authorizer initialized with database caching")
|
|
|
|
// Set global readme cache for middleware
|
|
middleware.SetGlobalReadmeCache(readmeCache)
|
|
slog.Info("README cache initialized for manifest push refresh")
|
|
|
|
// 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 OAuth server
|
|
oauthServer := oauth.NewServer(oauthApp)
|
|
// Connect server to refresher for cache invalidation
|
|
oauthServer.SetRefresher(refresher)
|
|
// Connect UI session store for web login
|
|
if uiSessionStore != nil {
|
|
oauthServer.SetUISessionStore(uiSessionStore)
|
|
}
|
|
|
|
// Register OAuth post-auth callback for AppView business logic
|
|
// This decouples the OAuth package from AppView-specific dependencies
|
|
oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
|
|
slog.Debug("OAuth post-auth callback", "component", "appview/callback", "did", did)
|
|
|
|
// Parse DID for session resume
|
|
didParsed, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
slog.Warn("Failed to parse DID", "component", "appview/callback", "did", did, "error", err)
|
|
return nil // Non-fatal
|
|
}
|
|
|
|
// Resume OAuth session to get authenticated client
|
|
session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID)
|
|
if err != nil {
|
|
slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err)
|
|
// Fallback: update user without avatar
|
|
_ = db.UpsertUser(uiDatabase, &db.User{
|
|
DID: did,
|
|
Handle: handle,
|
|
PDSEndpoint: pdsEndpoint,
|
|
Avatar: "",
|
|
LastSeen: time.Now(),
|
|
})
|
|
return nil // Non-fatal
|
|
}
|
|
|
|
// Create authenticated atproto client using the indigo session's API client
|
|
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
|
|
|
|
// Ensure sailor profile exists (creates with default hold if configured)
|
|
slog.Debug("Ensuring profile exists", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
|
|
if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil {
|
|
slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err)
|
|
// Continue anyway - profile creation is not critical for avatar fetch
|
|
} else {
|
|
slog.Debug("Profile ensured", "component", "appview/callback", "did", did)
|
|
}
|
|
|
|
// Fetch user's profile record from PDS (contains blob references)
|
|
profileRecord, err := client.GetProfileRecord(ctx, did)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch profile record", "component", "appview/callback", "did", did, "error", err)
|
|
// Still update user without avatar
|
|
_ = db.UpsertUser(uiDatabase, &db.User{
|
|
DID: did,
|
|
Handle: handle,
|
|
PDSEndpoint: pdsEndpoint,
|
|
Avatar: "",
|
|
LastSeen: time.Now(),
|
|
})
|
|
return nil // Non-fatal
|
|
}
|
|
|
|
// Construct avatar URL from blob CID using imgs.blue CDN
|
|
var avatarURL string
|
|
if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
|
|
avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
|
|
slog.Debug("Constructed avatar URL", "component", "appview/callback", "avatar_url", avatarURL)
|
|
}
|
|
|
|
// Store user with avatar in database
|
|
err = db.UpsertUser(uiDatabase, &db.User{
|
|
DID: did,
|
|
Handle: handle,
|
|
PDSEndpoint: pdsEndpoint,
|
|
Avatar: avatarURL,
|
|
LastSeen: time.Now(),
|
|
})
|
|
if err != nil {
|
|
slog.Warn("Failed to store user in database", "component", "appview/callback", "error", err)
|
|
return nil // Non-fatal
|
|
}
|
|
|
|
slog.Debug("Stored user with avatar", "component", "appview/callback", "did", did)
|
|
|
|
// Migrate profile URL→DID if needed
|
|
profile, err := storage.GetProfile(ctx, client)
|
|
if err != nil {
|
|
slog.Warn("Failed to get profile", "component", "appview/callback", "did", did, "error", err)
|
|
return nil // Non-fatal
|
|
}
|
|
|
|
var holdDID string
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
// Check if defaultHold is a URL (needs migration)
|
|
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
|
|
slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold)
|
|
|
|
// Resolve URL to DID
|
|
holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
|
|
|
|
// Update profile with DID
|
|
profile.DefaultHold = holdDID
|
|
if err := storage.UpdateProfile(ctx, client, profile); err != nil {
|
|
slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
|
|
} else {
|
|
slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID)
|
|
}
|
|
slog.Debug("Attempting crew registration", "component", "oauth/server", "did", did, "hold_did", holdDID)
|
|
storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
|
|
} else {
|
|
// Already a DID - use it
|
|
holdDID = profile.DefaultHold
|
|
}
|
|
// Register crew regardless of migration (outside the migration block)
|
|
slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID)
|
|
storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
|
|
|
|
}
|
|
|
|
return nil // All errors are non-fatal, logged for debugging
|
|
})
|
|
|
|
// Create token issuer (also initializes auth keys if needed)
|
|
var issuer *token.Issuer
|
|
if cfg.Distribution.Auth["token"] != nil {
|
|
issuer, err = createTokenIssuer(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create token issuer: %w", err)
|
|
}
|
|
|
|
// Log successful initialization
|
|
slog.Info("Auth keys initialized", "path", cfg.Auth.KeyPath)
|
|
}
|
|
|
|
// Create registry app (returns http.Handler)
|
|
ctx := context.Background()
|
|
app := handlers.NewApp(ctx, cfg.Distribution)
|
|
|
|
// Create main HTTP mux
|
|
mux := http.NewServeMux()
|
|
|
|
// Mount registry at /v2/
|
|
mux.Handle("/v2/", app)
|
|
|
|
// Mount UI routes if enabled
|
|
if uiSessionStore != nil && uiTemplates != nil && uiRouter != nil {
|
|
// Mount static files
|
|
mux.Handle("/static/", http.StripPrefix("/static/", appview.StaticHandler()))
|
|
|
|
// Mount UI routes directly at root level
|
|
mux.Handle("/", uiRouter)
|
|
|
|
slog.Info("UI enabled", "home", "/", "settings", "/settings")
|
|
}
|
|
|
|
// Mount OAuth endpoints
|
|
mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize)
|
|
mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback)
|
|
|
|
// OAuth client metadata endpoint
|
|
mux.HandleFunc("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
|
|
config := oauthApp.GetConfig()
|
|
metadata := config.ClientMetadata()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
|
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
|
|
}
|
|
})
|
|
|
|
// Note: Indigo handles OAuth state cleanup internally via its store
|
|
|
|
// Mount auth endpoints if enabled
|
|
if issuer != nil {
|
|
// Basic Auth token endpoint (supports device secrets and app passwords)
|
|
tokenHandler := token.NewHandler(issuer, deviceStore)
|
|
|
|
// Register token post-auth callback for profile management
|
|
// This decouples the token package from AppView-specific dependencies
|
|
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
|
|
slog.Debug("Token post-auth callback", "component", "appview/callback", "did", did)
|
|
|
|
// Create ATProto client with validated token
|
|
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
|
|
|
|
// Ensure profile exists (will create with default hold if not exists and default is configured)
|
|
if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil {
|
|
// Log error but don't fail auth - profile management is not critical
|
|
slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err)
|
|
} else {
|
|
slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
|
|
}
|
|
|
|
return nil // All errors are non-fatal
|
|
})
|
|
|
|
tokenHandler.RegisterRoutes(mux)
|
|
|
|
// Device authorization endpoints (public)
|
|
mux.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{
|
|
Store: deviceStore,
|
|
AppViewBaseURL: baseURL,
|
|
})
|
|
mux.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{
|
|
Store: deviceStore,
|
|
})
|
|
|
|
slog.Info("Auth endpoints enabled",
|
|
"basic_auth", "/auth/token",
|
|
"device_code", "/auth/device/code",
|
|
"device_token", "/auth/device/token",
|
|
"oauth_authorize", "/auth/oauth/authorize",
|
|
"oauth_callback", "/auth/oauth/callback",
|
|
"oauth_metadata", "/client-metadata.json")
|
|
}
|
|
|
|
// Create HTTP server
|
|
server := &http.Server{
|
|
Addr: cfg.Server.Addr,
|
|
Handler: mux,
|
|
}
|
|
|
|
// Handle graceful shutdown
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
|
|
// Start server in goroutine
|
|
errChan := make(chan error, 1)
|
|
go func() {
|
|
slog.Info("Starting registry server", "addr", cfg.Server.Addr)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
errChan <- err
|
|
}
|
|
}()
|
|
|
|
// Wait for shutdown signal or error
|
|
select {
|
|
case <-stop:
|
|
slog.Info("Shutting down registry server")
|
|
|
|
// Stop health worker first
|
|
slog.Info("Stopping hold health worker")
|
|
healthWorker.Stop()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
return fmt.Errorf("server shutdown error: %w", err)
|
|
}
|
|
case err := <-errChan:
|
|
// Stop health worker on error (workerCancel called by defer)
|
|
healthWorker.Stop()
|
|
return fmt.Errorf("server error: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createTokenIssuer creates a token issuer for auth handlers
|
|
func createTokenIssuer(cfg *appview.Config) (*token.Issuer, error) {
|
|
return token.NewIssuer(
|
|
cfg.Auth.KeyPath,
|
|
cfg.Auth.ServiceName, // issuer
|
|
cfg.Auth.ServiceName, // service
|
|
cfg.Auth.TokenExpiration,
|
|
)
|
|
}
|
|
|
|
// 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
|
|
jetstreamURL := jetstreamCfg.URL
|
|
|
|
// Start real-time Jetstream worker with cursor tracking for reconnects
|
|
go func() {
|
|
var lastCursor int64 = 0 // Start from now on first connect
|
|
for {
|
|
worker := jetstream.NewWorker(database, jetstreamURL, lastCursor)
|
|
if err := worker.Start(context.Background()); err != nil {
|
|
// Save cursor from this connection for next reconnect
|
|
lastCursor = worker.GetLastCursor()
|
|
slog.Warn("Jetstream real-time worker error, reconnecting", "component", "jetstream", "error", err, "reconnect_delay", "10s")
|
|
time.Sleep(10 * time.Second)
|
|
}
|
|
}
|
|
}()
|
|
slog.Info("Jetstream real-time worker started", "component", "jetstream")
|
|
|
|
// Start backfill worker (enabled by default, set ATCR_BACKFILL_ENABLED=false to disable)
|
|
if jetstreamCfg.BackfillEnabled {
|
|
// Get relay endpoint for sync API (defaults to Bluesky's relay)
|
|
relayEndpoint := jetstreamCfg.RelayEndpoint
|
|
|
|
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode)
|
|
if err != nil {
|
|
slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err)
|
|
} else {
|
|
// Run initial backfill with startup delay for Docker compose
|
|
go func() {
|
|
// Wait for hold service to be ready (Docker startup race condition)
|
|
startupDelay := 5 * time.Second
|
|
slog.Info("Waiting for services to be ready", "component", "jetstream/backfill", "startup_delay", startupDelay)
|
|
time.Sleep(startupDelay)
|
|
|
|
slog.Info("Starting sync-based backfill", "component", "jetstream/backfill", "relay_endpoint", relayEndpoint)
|
|
if err := backfillWorker.Start(context.Background()); err != nil {
|
|
slog.Warn("Backfill finished with error", "component", "jetstream/backfill", "error", err)
|
|
} else {
|
|
slog.Info("Backfill completed successfully", "component", "jetstream/backfill")
|
|
}
|
|
}()
|
|
|
|
// Start periodic backfill scheduler
|
|
interval := jetstreamCfg.BackfillInterval
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval)
|
|
if err := backfillWorker.Start(context.Background()); err != nil {
|
|
slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err)
|
|
} else {
|
|
slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill")
|
|
}
|
|
}
|
|
}()
|
|
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
|
|
}
|
|
}
|
|
}
|