mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
The link was hardcoded beforefa34da0too — it pointed at bsky.app/profile/atcr.io. That commit was right to switch to a DID, since handles change and a stale handle link breaks silently, but the DID went into components/footer.html, a shared template. Every self-hoster's footer therefore links to the project's Bluesky account. Now ui.bluesky_profile, following source_url in the same footer exactly: config field with a default, plumbed through UIDependencies and PageData, and guarded with {{ with }} so an unset value omits the link rather than rendering something wrong. It takes a handle or a DID; the comment says to prefer a DID for the reasonfa34da0changed it. Defaulting to the project account matches source_url's logic — both name the upstream project rather than the operator — and self-hosters who want their own or none set one line. The aria-label switched to $.ClientShortName: `with` rebinds the dot, so the label would otherwise have silently rendered empty. Two things worth recording: * `{{ with }}` hides the link on an empty value, but you cannot get an empty value from the environment. Viper runs with AllowEmptyEnv(false), so an empty env var reads as unset and the default wins. Only "" in YAML works. That applies to every string field in this config, not just this one, and the comment now says so. * config-appview.example.yaml must NOT be regenerated with `config init`, despite what the checklist in CLAUDE.md says. The file is hand-curated well past the defaults, and regenerating replaces real Stripe price IDs with price_xxx placeholders and blanks registry_domains, managed_holds, theme and the tier names. Added by hand instead. Verified live both ways: the configured value renders, and `bluesky_profile: ""` in YAML drops the link while leaving the Source link intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
1123 lines
42 KiB
Go
1123 lines
42 KiB
Go
package appview
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
"github.com/distribution/distribution/v3/registry/handlers"
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
|
|
"atcr.io/pkg/appview/authgate"
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/holdhealth"
|
|
"atcr.io/pkg/appview/jetstream"
|
|
appviewlabeler "atcr.io/pkg/appview/labeler"
|
|
"atcr.io/pkg/appview/leases"
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/appview/readme"
|
|
"atcr.io/pkg/appview/registryauth"
|
|
"atcr.io/pkg/appview/routes"
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/appview/webhooks"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/oauth"
|
|
"atcr.io/pkg/auth/token"
|
|
"atcr.io/pkg/billing"
|
|
"atcr.io/pkg/logging"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
|
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
)
|
|
|
|
// OAuthPostAuthHook is called after the default OAuth post-auth logic
|
|
// (profile creation, avatar fetch, crew registration). Hooks added after
|
|
// NewAppViewServer but before the first request work correctly.
|
|
type OAuthPostAuthHook func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error
|
|
|
|
// TokenPostAuthHook is called after the default token post-auth logic
|
|
// (profile creation). Hooks added after NewAppViewServer but before the
|
|
// first request work correctly.
|
|
type TokenPostAuthHook func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
|
|
|
|
// AppViewServer is the AppView service with an exposed router for extensibility.
|
|
// Consumers can add routes to Router and hooks before calling Serve().
|
|
type AppViewServer struct {
|
|
// Router is the chi router. Add routes before calling Serve().
|
|
Router chi.Router
|
|
|
|
// Config is the AppView configuration.
|
|
Config *Config
|
|
|
|
// Database is the read-write SQLite database.
|
|
Database *sql.DB
|
|
|
|
// ReadOnlyDB is the read-only SQLite database connection.
|
|
ReadOnlyDB *sql.DB
|
|
|
|
// SessionStore manages web UI sessions.
|
|
SessionStore *db.SessionStore
|
|
|
|
// DeviceStore manages device authorization flows.
|
|
DeviceStore *db.DeviceStore
|
|
|
|
// OAuthStore manages OAuth session persistence.
|
|
OAuthStore *db.OAuthStore
|
|
|
|
// OAuthServer handles OAuth authorization and callback endpoints.
|
|
OAuthServer *oauth.Server
|
|
|
|
// OAuthClientApp is the indigo OAuth client application.
|
|
OAuthClientApp *indigooauth.ClientApp
|
|
|
|
// Refresher manages OAuth session refresh and caching.
|
|
Refresher *oauth.Refresher
|
|
|
|
// Templates are the parsed HTML templates.
|
|
Templates *template.Template
|
|
|
|
// HealthChecker checks hold service health.
|
|
HealthChecker *holdhealth.Checker
|
|
|
|
// ReadmeFetcher fetches README content for repository pages.
|
|
ReadmeFetcher *readme.Fetcher
|
|
|
|
// TokenIssuer issues registry JWTs (nil if auth is not configured).
|
|
TokenIssuer *token.Issuer
|
|
|
|
// HoldAuthorizer checks hold access permissions.
|
|
HoldAuthorizer auth.HoldAuthorizer
|
|
|
|
// OAuthKey is the P-256 private key used for OAuth client auth and appview service identity.
|
|
OAuthKey *atcrypto.PrivateKeyP256
|
|
|
|
// BillingManager handles Stripe billing and tier updates (nil if billing disabled).
|
|
BillingManager *billing.Manager
|
|
|
|
// WebhookDispatcher dispatches scan webhooks (stored in appview DB).
|
|
WebhookDispatcher *webhooks.Dispatcher
|
|
|
|
// Leases elects a single instance to run each background worker.
|
|
Leases *leases.Manager
|
|
|
|
// Private fields for lifecycle management
|
|
oauthHooks []OAuthPostAuthHook
|
|
tokenHooks []TokenPostAuthHook
|
|
httpServer *http.Server
|
|
healthWorker *holdhealth.Worker
|
|
workerCancel context.CancelFunc
|
|
branding *BrandingOverrides
|
|
}
|
|
|
|
// AddOAuthPostAuthHook registers a hook that runs after the default OAuth
|
|
// post-auth logic. Multiple hooks run in registration order.
|
|
func (s *AppViewServer) AddOAuthPostAuthHook(hook OAuthPostAuthHook) {
|
|
s.oauthHooks = append(s.oauthHooks, hook)
|
|
}
|
|
|
|
// AddTokenPostAuthHook registers a hook that runs after the default token
|
|
// post-auth logic. Multiple hooks run in registration order.
|
|
func (s *AppViewServer) AddTokenPostAuthHook(hook TokenPostAuthHook) {
|
|
s.tokenHooks = append(s.tokenHooks, hook)
|
|
}
|
|
|
|
// NewAppViewServer creates a fully-initialized AppView server ready for the
|
|
// consumer to add routes and hooks before calling Serve(). Pass nil for
|
|
// branding to use default atcr.io assets and templates.
|
|
func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, error) {
|
|
// Initialize structured logging with optional remote shipping
|
|
logging.InitLoggerWithShipper(cfg.LogLevel, logging.ShipperConfig{
|
|
Backend: cfg.LogShipper.Backend,
|
|
URL: cfg.LogShipper.URL,
|
|
BatchSize: cfg.LogShipper.BatchSize,
|
|
FlushInterval: cfg.LogShipper.FlushInterval,
|
|
Service: "appview",
|
|
Username: cfg.LogShipper.Username,
|
|
Password: cfg.LogShipper.Password,
|
|
})
|
|
|
|
slog.Info("Configuration loaded successfully from environment")
|
|
if cfg.AI.APIKey != "" {
|
|
slog.Info("AI Image Advisor enabled")
|
|
}
|
|
|
|
s := &AppViewServer{
|
|
Config: cfg,
|
|
branding: branding,
|
|
}
|
|
|
|
// Initialize UI database (required for all stores)
|
|
slog.Info("Initializing UI database", "path", cfg.UI.DatabasePath)
|
|
libsqlCfg := db.LibsqlConfig{
|
|
SyncURL: cfg.UI.LibsqlSyncURL,
|
|
AuthToken: cfg.UI.LibsqlAuthToken,
|
|
SyncInterval: cfg.UI.LibsqlSyncInterval,
|
|
}
|
|
s.Database, s.ReadOnlyDB, s.SessionStore = db.InitializeDatabase(cfg.UI.DatabasePath, libsqlCfg)
|
|
if s.Database == nil {
|
|
return nil, 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)
|
|
s.HealthChecker = holdhealth.NewChecker(cfg.Health.CacheTTL)
|
|
|
|
// Initialize README fetcher for rendering repo page descriptions
|
|
s.ReadmeFetcher = readme.NewFetcher()
|
|
|
|
// Start background health check worker
|
|
startupDelay := 5 * time.Second
|
|
dbAdapter := holdhealth.NewDBAdapter(s.Database)
|
|
s.healthWorker = holdhealth.NewWorkerWithStartupDelay(s.HealthChecker, dbAdapter, cfg.Health.CheckInterval, startupDelay)
|
|
|
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
|
s.workerCancel = workerCancel
|
|
s.healthWorker.Start(workerCtx)
|
|
slog.Info("Hold health worker started", "startup_delay", startupDelay, "refresh_interval", cfg.Health.CheckInterval, "cache_ttl", cfg.Health.CacheTTL)
|
|
|
|
// Leader election for the singleton background workers. The health worker
|
|
// above is deliberately not among them: it only refreshes a cache that each
|
|
// instance needs locally, so running it everywhere is correct.
|
|
s.Leases = leases.NewManager(s.Database, leases.Config{
|
|
Enabled: cfg.Leases.Enabled,
|
|
TTL: cfg.Leases.TTL,
|
|
RenewInterval: cfg.Leases.RenewInterval,
|
|
})
|
|
slog.Info("Lease manager initialized",
|
|
"component", "leases", "enabled", cfg.Leases.Enabled, "holder", s.Leases.HolderID())
|
|
|
|
// The cleanup worker starts later, once the hold authorizer exists: it
|
|
// clears the crew denial backoffs as its first act and needs a handle on it.
|
|
|
|
// Initialize OAuth components
|
|
slog.Info("Initializing OAuth components")
|
|
|
|
s.OAuthStore = db.NewOAuthStore(s.Database)
|
|
slog.Info("Using SQLite for OAuth session storage")
|
|
|
|
s.DeviceStore = db.NewDeviceStore(s.Database)
|
|
slog.Info("Using SQLite for device storage")
|
|
|
|
baseURL := cfg.Server.BaseURL
|
|
defaultHoldDID := cfg.Server.PrimaryHoldDID()
|
|
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")
|
|
atproto.SetTestMode(true)
|
|
}
|
|
|
|
oauthKey, err := loadOAuthKey(s.Database)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load OAuth key: %w", err)
|
|
}
|
|
s.OAuthKey = oauthKey
|
|
|
|
// Create OAuth client app
|
|
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
|
|
s.OAuthClientApp, err = oauth.NewClientAppWithKey(baseURL, s.OAuthStore, desiredScopes, oauthKey, cfg.Server.ClientName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create OAuth client app: %w", err)
|
|
}
|
|
|
|
// Invalidate sessions with mismatched scopes on startup
|
|
invalidatedCount, err := s.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
|
|
s.Refresher = oauth.NewRefresher(s.OAuthClientApp)
|
|
|
|
// Wire up UI session store to refresher
|
|
if s.SessionStore != nil {
|
|
s.Refresher.SetUISessionStore(s.SessionStore)
|
|
}
|
|
|
|
// Set global refresher for middleware
|
|
middleware.SetGlobalRefresher(s.Refresher)
|
|
|
|
// Set global database for hold DID lookups and manifest reference checks
|
|
holdDIDDB := db.NewHoldDIDDB(s.Database)
|
|
middleware.SetGlobalDatabase(holdDIDDB)
|
|
middleware.SetGlobalManifestRefChecker(holdDIDDB)
|
|
|
|
// Set label checker for takedown filtering
|
|
middleware.SetGlobalLabelChecker(db.NewLabelChecker(s.Database))
|
|
|
|
// Create RemoteHoldAuthorizer for hold authorization with caching
|
|
s.HoldAuthorizer = auth.NewRemoteHoldAuthorizer(s.Database, testMode)
|
|
middleware.SetGlobalAuthorizer(s.HoldAuthorizer)
|
|
slog.Info("Hold authorizer initialized with database caching")
|
|
|
|
// Session, OAuth and device-flow cleanup, plus the one-shot denial-cache
|
|
// clear. Started here rather than next to the other workers because it needs
|
|
// the hold authorizer above.
|
|
s.startCleanupWorker(workerCtx)
|
|
|
|
// Initialize billing manager
|
|
appviewDID := DIDFromBaseURL(baseURL)
|
|
s.BillingManager = billing.New(
|
|
&cfg.Billing,
|
|
oauthKey,
|
|
appviewDID,
|
|
cfg.Server.ManagedHolds,
|
|
baseURL,
|
|
s.Database,
|
|
)
|
|
// Allow hold captains to bypass billing feature gates
|
|
if len(cfg.Server.ManagedHolds) > 0 {
|
|
managedHolds := cfg.Server.ManagedHolds
|
|
roDB := s.ReadOnlyDB
|
|
s.BillingManager.SetCaptainChecker(func(userDID string) bool {
|
|
isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds)
|
|
return isCaptain
|
|
})
|
|
// Paid features require the user's active (default) hold to be a managed
|
|
// hold. An unset default hold means they use the operator's primary
|
|
// managed hold, so it counts as managed. Read the primary DB (not roDB):
|
|
// a hold switch writes default_hold_did to the primary, and the replica
|
|
// may lag, so reading roDB could keep paid features alive briefly after a
|
|
// switch to self-hosted.
|
|
primaryDB := s.Database
|
|
s.BillingManager.SetActiveHoldChecker(func(userDID string) bool {
|
|
holdDID := db.GetUserDefaultHoldDID(primaryDB, userDID)
|
|
return holdDID == "" || slices.Contains(managedHolds, holdDID)
|
|
})
|
|
}
|
|
if s.BillingManager.Enabled() {
|
|
// Fail closed: an empty Stripe webhook secret makes webhooks forgeable
|
|
// (Stripe HMACs with the empty key, which an attacker can reproduce).
|
|
if !s.BillingManager.WebhookConfigured() {
|
|
return nil, fmt.Errorf("billing is enabled but STRIPE_WEBHOOK_SECRET is not set; refusing to start with a forgeable webhook endpoint")
|
|
}
|
|
slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds))
|
|
// Leased: RefreshHoldTiers writes tier state derived from Stripe, and
|
|
// several instances refreshing the same holds concurrently would race
|
|
// on those writes for no benefit.
|
|
s.Leases.Go(workerCtx, db.LeaseBillingTiers, func(context.Context) error {
|
|
s.BillingManager.RefreshHoldTiers()
|
|
return nil // one-shot; the lease is released when it returns
|
|
})
|
|
}
|
|
|
|
// Create webhook dispatcher
|
|
appviewMeta := atproto.AppviewMetadata{
|
|
ClientName: cfg.Server.ClientName,
|
|
ClientShortName: cfg.Server.ClientShortName,
|
|
BaseURL: cfg.Server.BaseURL,
|
|
FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png",
|
|
RegistryDomains: cfg.Server.RegistryDomains,
|
|
}
|
|
// Gate dispatch on the same entitlement the creation form uses, so webhooks
|
|
// stop firing paid behavior when a user loses entitlement (switches to a
|
|
// self-hosted hold or downgrades) after creating them.
|
|
s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta, s.BillingManager.GetWebhookLimits)
|
|
middleware.SetGlobalWebhookDispatcher(s.WebhookDispatcher)
|
|
|
|
// Initialize Jetstream workers
|
|
s.initializeJetstream(workerCtx)
|
|
|
|
// Initialize labeler subscriber. Leased per labeler DID, matching the
|
|
// per-src cursor in labeler_cursor: two subscribers on one labeler would
|
|
// each advance the same cursor and double-apply every takedown.
|
|
if cfg.Labeler.DID != "" {
|
|
labelerDID := cfg.Labeler.DID
|
|
s.Leases.Go(workerCtx, db.LeaseLabeler(labelerDID), func(ctx context.Context) error {
|
|
// Built inside the worker because Stop closes a channel and cannot
|
|
// be called twice; a retry after a lost lease needs a fresh one.
|
|
sub := appviewlabeler.SubscriberFromConfig(labelerDID, s.Database)
|
|
if sub == nil {
|
|
return fmt.Errorf("labeler subscriber unavailable for %q", labelerDID)
|
|
}
|
|
sub.Start()
|
|
slog.Info("Labeler subscriber started", "labeler", labelerDID)
|
|
<-ctx.Done()
|
|
sub.Stop()
|
|
return ctx.Err()
|
|
})
|
|
}
|
|
|
|
// Create main chi router
|
|
mainRouter := chi.NewRouter()
|
|
|
|
mainRouter.Use(chimiddleware.RealIP)
|
|
mainRouter.Use(chimiddleware.Logger)
|
|
mainRouter.Use(chimiddleware.Recoverer)
|
|
mainRouter.Use(chimiddleware.GetHead)
|
|
mainRouter.Use(routes.CORSMiddleware())
|
|
// Vanity import prefix must match the `module` line in go.mod exactly,
|
|
// otherwise `go install` fails the module-path check.
|
|
mainRouter.Use(middleware.GoImport("atcr.io", cfg.UI.SourceURL))
|
|
|
|
// Domain routing middleware. Gated on the raw config (an empty
|
|
// registry_domains means single-domain, where /v2/ must stay on the UI
|
|
// host) but fed cfg.Auth.Services, which is the same list normalized. The
|
|
// middleware matches a port-stripped host, so a domain configured with a
|
|
// port could never match its own requests when compared raw; sharing the
|
|
// normalized list also keeps routing and the access controller agreeing on
|
|
// exactly one set of names.
|
|
if len(cfg.Server.RegistryDomains) > 0 {
|
|
mainRouter.Use(DomainRoutingMiddleware(cfg.Auth.Services, cfg.Server.BaseURL))
|
|
slog.Info("Domain routing middleware enabled",
|
|
"registry_domains", cfg.Auth.Services,
|
|
"ui_base_url", cfg.Server.BaseURL)
|
|
}
|
|
|
|
// Load templates
|
|
ComputeAssetHashes(branding)
|
|
s.Templates, err = Templates(branding)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load UI templates: %w", err)
|
|
}
|
|
|
|
// Register UI routes
|
|
routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
|
|
Database: s.Database,
|
|
ReadOnlyDB: s.ReadOnlyDB,
|
|
SessionStore: s.SessionStore,
|
|
OAuthClientApp: s.OAuthClientApp,
|
|
OAuthStore: s.OAuthStore,
|
|
Refresher: s.Refresher,
|
|
BaseURL: baseURL,
|
|
RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
|
|
RegistryDomains: cfg.Server.RegistryDomains,
|
|
DeviceStore: s.DeviceStore,
|
|
HealthChecker: s.HealthChecker,
|
|
ReadmeFetcher: s.ReadmeFetcher,
|
|
Templates: s.Templates,
|
|
DefaultHoldDID: defaultHoldDID,
|
|
ManagedHolds: cfg.Server.ManagedHolds,
|
|
ClientName: cfg.Server.ClientName,
|
|
ClientShortName: cfg.Server.ClientShortName,
|
|
BillingManager: s.BillingManager,
|
|
WebhookDispatcher: s.WebhookDispatcher,
|
|
ClaudeAPIKey: cfg.AI.APIKey,
|
|
SourceURL: cfg.UI.SourceURL,
|
|
BlueskyProfile: cfg.UI.BlueskyProfile,
|
|
LegalConfig: routes.LegalConfig{
|
|
CompanyName: cfg.Legal.CompanyName,
|
|
Jurisdiction: cfg.Legal.Jurisdiction,
|
|
},
|
|
})
|
|
|
|
// Register Stripe webhook route (if billing enabled)
|
|
s.BillingManager.RegisterRoutes(mainRouter)
|
|
|
|
// Create OAuth server
|
|
s.OAuthServer = oauth.NewServer(s.OAuthClientApp)
|
|
s.OAuthServer.SetRefresher(s.Refresher)
|
|
if s.SessionStore != nil {
|
|
s.OAuthServer.SetUISessionStore(s.SessionStore)
|
|
}
|
|
|
|
// Register OAuth post-auth callback (closure captures s for hook dispatch)
|
|
s.OAuthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
|
|
slog.Debug("OAuth post-auth callback", "component", "appview/callback", "did", did)
|
|
|
|
// Create ATProto client with session provider
|
|
client := atproto.NewClientWithSessionProvider(pdsEndpoint, did, s.Refresher)
|
|
|
|
// Ensure sailor profile exists
|
|
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)
|
|
} else {
|
|
slog.Debug("Profile ensured", "component", "appview/callback", "did", did)
|
|
}
|
|
|
|
// Fetch user's profile record from PDS
|
|
profileRecord, err := client.GetProfileRecord(ctx, did)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch profile record", "component", "appview/callback", "did", did, "error", err)
|
|
profileRecord = nil
|
|
}
|
|
|
|
// Construct avatar URL from blob CID
|
|
avatarURL := ""
|
|
if profileRecord != nil && 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 in database
|
|
if avatarURL != "" {
|
|
err = db.UpsertUser(s.Database, &db.User{
|
|
DID: did,
|
|
Handle: handle,
|
|
PDSEndpoint: pdsEndpoint,
|
|
Avatar: avatarURL,
|
|
LastSeen: time.Now(),
|
|
})
|
|
} else {
|
|
err = db.UpsertUserIgnoreAvatar(s.Database, &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
|
|
}
|
|
|
|
slog.Debug("Stored user", "component", "appview/callback", "did", did, "has_avatar", avatarURL != "")
|
|
|
|
// 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
|
|
}
|
|
|
|
var holdDID string
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
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)
|
|
|
|
if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold); resolveErr != nil {
|
|
slog.Warn("Failed to resolve hold DID from URL", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold, "error", resolveErr)
|
|
} else {
|
|
holdDID = resolvedDID
|
|
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)
|
|
}
|
|
}
|
|
} else {
|
|
holdDID = profile.DefaultHold
|
|
}
|
|
// Register crew in background
|
|
slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID)
|
|
go func(userDID, pdsEndpoint, holdDID string, refresher *oauth.Refresher, authorizer auth.HoldAuthorizer) {
|
|
ctx := context.Background()
|
|
storage.EnsureCrewMembership(ctx, userDID, holdDID, authorizer,
|
|
func(ctx context.Context, holdDID string) (string, error) {
|
|
return auth.GetOrFetchServiceToken(ctx, refresher, userDID, holdDID, pdsEndpoint)
|
|
})
|
|
}(did, pdsEndpoint, holdDID, s.Refresher, s.HoldAuthorizer)
|
|
}
|
|
|
|
// Drain manifests from old hold to successor in background
|
|
go func(client *atproto.Client, did string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
storage.MigrateManifestsForSuccessor(ctx, client, s.HoldAuthorizer, db.NewHoldDIDDB(s.Database), did)
|
|
}(client, did)
|
|
|
|
// Run consumer hooks
|
|
for _, hook := range s.oauthHooks {
|
|
if err := hook(ctx, did, handle, pdsEndpoint, sessionID); err != nil {
|
|
slog.Warn("OAuth post-auth hook error", "component", "appview/callback", "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// Create token issuer
|
|
if cfg.Distribution.Auth[registryauth.AuthType] != nil {
|
|
rsaKey, certDER, err := loadJWTKeyAndCert(s.Database, cfg.Auth.CertPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load JWT key material: %w", err)
|
|
}
|
|
s.TokenIssuer = token.NewIssuerFromKey(rsaKey, certDER, cfg.Auth.PrimaryService(), cfg.Auth.PrimaryService(), cfg.Auth.TokenExpiration)
|
|
slog.Info("Auth keys initialized")
|
|
}
|
|
|
|
// Create registry app (distribution library handler)
|
|
ctx := context.Background()
|
|
app := handlers.NewApp(ctx, cfg.Distribution)
|
|
|
|
// Wrap with auth method extraction middleware, then with the Retry-After
|
|
// emitter so it can read the carrier installed before deeper handlers run.
|
|
// Outermost is the Bearer challenge guard, so any 401 from deep in the stack
|
|
// (e.g. anonymous read of a private hold) still carries WWW-Authenticate.
|
|
wrappedApp := middleware.BearerChallenge(cfg.Server.BaseURL+"/auth/token", cfg.Auth.Services)(
|
|
middleware.RetryAfterMiddleware(middleware.ExtractAuthMethod(app)))
|
|
|
|
// Mount registry at /v2/
|
|
mainRouter.Handle("/v2/*", wrappedApp)
|
|
|
|
// Mount static files
|
|
if s.SessionStore != nil && s.Templates != nil {
|
|
publicHandler := CacheMiddleware(PublicHandler(branding), 31536000)
|
|
rootFiles, err := PublicRootFiles(branding)
|
|
if err != nil {
|
|
slog.Warn("Failed to scan static root files", "error", err)
|
|
} else {
|
|
for _, filename := range rootFiles {
|
|
file := filename
|
|
mainRouter.Get("/"+file, func(w http.ResponseWriter, r *http.Request) {
|
|
r.URL.Path = "/" + file
|
|
publicHandler.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
slog.Info("Registered dynamic root file routes", "count", len(rootFiles), "files", rootFiles)
|
|
}
|
|
|
|
mainRouter.Handle("/css/*", CacheMiddleware(http.StripPrefix("/css/", PublicSubdir("css", branding)), 31536000))
|
|
mainRouter.Handle("/js/*", CacheMiddleware(http.StripPrefix("/js/", PublicSubdir("js", branding)), 31536000))
|
|
mainRouter.Handle("/fonts/*", CacheMiddleware(http.StripPrefix("/fonts/", PublicSubdir("fonts", branding)), 31536000))
|
|
mainRouter.Handle("/static/*", CacheMiddleware(http.StripPrefix("/static/", PublicSubdir("static", branding)), 31536000))
|
|
|
|
slog.Info("UI enabled", "home", "/", "settings", "/settings")
|
|
}
|
|
|
|
// Mount OAuth endpoints
|
|
mainRouter.Get("/auth/oauth/authorize", s.OAuthServer.ServeAuthorize)
|
|
mainRouter.Get("/auth/oauth/callback", s.OAuthServer.ServeCallback)
|
|
|
|
// OAuth client metadata endpoint
|
|
mainRouter.Get("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
|
|
config := s.OAuthClientApp.Config
|
|
logoURI := cfg.Server.BaseURL + "/web-app-manifest-192x192.png"
|
|
policyURI := cfg.Server.BaseURL + "/privacy"
|
|
tosURI := cfg.Server.BaseURL + "/terms"
|
|
|
|
metadata := config.ClientMetadata()
|
|
metadata.ClientName = &cfg.Server.ClientName
|
|
metadata.ClientURI = &cfg.Server.BaseURL
|
|
metadata.LogoURI = &logoURI
|
|
metadata.PolicyURI = &policyURI
|
|
metadata.TosURI = &tosURI
|
|
|
|
if config.IsConfidential() && metadata.JWKS == nil {
|
|
jwks := config.PublicJWKS()
|
|
metadata.JWKS = &jwks
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
|
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
|
|
}
|
|
})
|
|
|
|
// Mount auth endpoints
|
|
if s.TokenIssuer != nil {
|
|
tokenHandler := token.NewHandler(s.TokenIssuer, s.DeviceStore)
|
|
|
|
// Stamp each JWT with the registry domain the client is pushing to, so
|
|
// the audience names the front door actually used.
|
|
tokenHandler.SetServices(cfg.Auth.Services)
|
|
|
|
tokenHandler.SetOAuthSessionValidator(s.Refresher)
|
|
|
|
// Auth-phase gate: crew reconciliation for any token request, plus
|
|
// hold membership + quota for non-wildcard push. Gating here means
|
|
// the JWT carries the authorization, so /v2/* doesn't need to
|
|
// re-check on every blob.
|
|
tokenHandler.SetAuthorizer(authgate.New(s.Database, s.HoldAuthorizer, s.Refresher, defaultHoldDID))
|
|
|
|
// Bind the registry JWT lifetime to the AppView↔hold service-auth.
|
|
// Pre-minting the service-auth here lets us stamp the JWT's exp
|
|
// from the cached expiry, so both expire concurrently.
|
|
tokenHandler.SetServiceAuthFetcher(authgate.NewServiceAuthFetcher(s.Database, s.Refresher, defaultHoldDID))
|
|
|
|
// Token post-auth callback (closure captures s for hook dispatch)
|
|
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
|
|
slog.Debug("Token post-auth callback", "component", "appview/callback", "did", did)
|
|
|
|
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
|
|
|
|
if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil {
|
|
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)
|
|
}
|
|
|
|
// Crew enrollment is handled synchronously by authgate.Authorize on
|
|
// the same /auth/token request, which also warms the approval cache.
|
|
// No duplicate goroutine needed here.
|
|
|
|
// Run consumer hooks
|
|
for _, hook := range s.tokenHooks {
|
|
if err := hook(ctx, did, handle, pdsEndpoint, accessToken); err != nil {
|
|
slog.Warn("Token post-auth hook error", "component", "appview/callback", "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// Both Docker token specs are served on the same path: GET is the
|
|
// original Basic-auth form, POST is the OAuth2 form that containerd and
|
|
// Docker try first whenever they hold a secret. Serving only GET made
|
|
// every such client eat a 405 and retry.
|
|
mainRouter.Get("/auth/token", tokenHandler.ServeHTTP)
|
|
mainRouter.Post("/auth/token", tokenHandler.ServeHTTP)
|
|
|
|
// Device authorization endpoints (public)
|
|
routes.RegisterDeviceEndpoints(mainRouter, s.DeviceStore, baseURL)
|
|
|
|
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", "/oauth-client-metadata.json")
|
|
}
|
|
|
|
// Health check endpoint (for Docker health checks / load balancers)
|
|
mainRouter.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
|
|
http.Error(w, "encode error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
})
|
|
|
|
// Appview metadata endpoint (public, used by holds for branding)
|
|
mainRouter.Get(atproto.AppviewGetMetadata, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
if err := json.NewEncoder(w).Encode(atproto.AppviewMetadata{
|
|
ClientName: cfg.Server.ClientName,
|
|
ClientShortName: cfg.Server.ClientShortName,
|
|
BaseURL: cfg.Server.BaseURL,
|
|
FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png",
|
|
RegistryDomains: cfg.Server.RegistryDomains,
|
|
}); err != nil {
|
|
http.Error(w, "encode error", http.StatusInternalServerError)
|
|
}
|
|
})
|
|
|
|
// Appview DID document endpoint (service identity for key discovery)
|
|
mainRouter.Get("/.well-known/did.json", s.handleDIDDocument)
|
|
|
|
s.Router = mainRouter
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Serve starts the HTTP server on the configured address and blocks until
|
|
// shutdown signal.
|
|
func (s *AppViewServer) Serve() error {
|
|
listener, err := net.Listen("tcp", s.Config.Server.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create listener: %w", err)
|
|
}
|
|
return s.ServeWithListener(listener)
|
|
}
|
|
|
|
// ServeWithListener starts the HTTP server on the provided listener and
|
|
// blocks until a shutdown signal arrives or Shutdown() is called. Tests use
|
|
// this to bind a 127.0.0.1:0 listener and learn the assigned port before
|
|
// driving requests.
|
|
func (s *AppViewServer) ServeWithListener(listener net.Listener) error {
|
|
s.httpServer = &http.Server{
|
|
Handler: s.Router,
|
|
}
|
|
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
defer signal.Stop(stop)
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() {
|
|
slog.Info("Starting registry server", "addr", listener.Addr().String())
|
|
err := s.httpServer.Serve(listener)
|
|
if err != nil && err != http.ErrServerClosed {
|
|
serveErr <- err
|
|
return
|
|
}
|
|
serveErr <- nil
|
|
}()
|
|
|
|
select {
|
|
case <-stop:
|
|
slog.Info("Shutting down registry server")
|
|
|
|
if s.Config.Server.TestMode {
|
|
listener.Close()
|
|
}
|
|
|
|
slog.Info("Stopping hold health worker")
|
|
s.healthWorker.Stop()
|
|
|
|
if s.workerCancel != nil {
|
|
s.workerCancel()
|
|
}
|
|
s.waitForLeasedWorkers()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil && err != http.ErrServerClosed {
|
|
logging.Shutdown()
|
|
return fmt.Errorf("server shutdown error: %w", err)
|
|
}
|
|
case err := <-serveErr:
|
|
s.healthWorker.Stop()
|
|
if s.workerCancel != nil {
|
|
s.workerCancel()
|
|
}
|
|
s.waitForLeasedWorkers()
|
|
logging.Shutdown()
|
|
if err != nil {
|
|
return fmt.Errorf("server error: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
logging.Shutdown()
|
|
return nil
|
|
}
|
|
|
|
// Shutdown gracefully stops the HTTP server. Safe to call from tests that
|
|
// don't want to send SIGTERM. Production code goes through the signal handler
|
|
// inside ServeWithListener.
|
|
func (s *AppViewServer) Shutdown(ctx context.Context) error {
|
|
if s.httpServer == nil {
|
|
return nil
|
|
}
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
// DomainRoutingMiddleware enforces three-tier domain routing:
|
|
//
|
|
// 1. UI domain (BaseURL hostname): serves web UI, auth, and static assets.
|
|
// Blocks /v2/* with an OCI UNSUPPORTED error — registry API lives on
|
|
// the dedicated registry domain(s).
|
|
// 2. Registry domains: allows /v2/* for Docker clients. Redirects everything
|
|
// else to the UI domain with 307 Temporary Redirect.
|
|
// 3. Unknown domains (CDN origins, IPs, etc.): redirects all requests to the
|
|
// UI domain with 307, except /health for load balancer probes.
|
|
func DomainRoutingMiddleware(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler {
|
|
// Request hosts are normalized to a bare hostname before matching, so the
|
|
// configured domains have to be normalized the same way. They frequently
|
|
// carry a port — config-appview.example.yaml ships "127.0.0.1:5000" — and
|
|
// matching those verbatim meant such an entry could never match anything.
|
|
regDomains := make(map[string]bool, len(registryDomains))
|
|
for _, d := range registryDomains {
|
|
regDomains[hostWithoutPort(d)] = true
|
|
}
|
|
|
|
// Extract UI hostname from BaseURL (e.g., "https://seamark.dev" -> "seamark.dev")
|
|
var uiHost string
|
|
if parsed, err := url.Parse(uiBaseURL); err == nil {
|
|
uiHost = parsed.Hostname()
|
|
}
|
|
|
|
primaryReg := primaryRegistryDomain(registryDomains)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
host := hostWithoutPort(r.Host)
|
|
|
|
path := r.URL.Path
|
|
isV2 := path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/")
|
|
|
|
switch {
|
|
case regDomains[host] && isV2:
|
|
// A registry domain gets /v2/* even when it is also the UI host.
|
|
// One host serving both is a legitimate deployment (it is what
|
|
// the dev stack is); checking uiHost first meant listing that
|
|
// host in registry_domains was silently ignored.
|
|
next.ServeHTTP(w, r)
|
|
|
|
case host == uiHost:
|
|
// UI domain: block /v2/*, serve everything else
|
|
if isV2 {
|
|
if err := errcode.ServeJSON(w, errcode.ErrorCodeUnsupported.WithMessage(
|
|
fmt.Sprintf("registry API is not available on this domain, use %s", primaryReg),
|
|
)); err != nil {
|
|
slog.Error("failed to write OCI error response", "error", err)
|
|
}
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
|
|
case regDomains[host]:
|
|
// Registry domain: allow /v2/*, /auth/token, /auth/device/*, redirect everything else
|
|
// Auth endpoints must be served directly to avoid 307 redirects that strip
|
|
// the Authorization header on cross-host redirects (Go http.Client behavior).
|
|
isAuth := path == "/auth/token" || strings.HasPrefix(path, "/auth/device/")
|
|
if isV2 || isAuth {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, uiBaseURL+r.URL.RequestURI(), http.StatusTemporaryRedirect)
|
|
|
|
default:
|
|
// Unknown domain: allow /health, redirect everything else
|
|
if path == "/health" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, uiBaseURL+r.URL.RequestURI(), http.StatusTemporaryRedirect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// hostWithoutPort strips a trailing :port from a host, leaving bare hostnames
|
|
// untouched. net.SplitHostPort is used rather than a LastIndex(":") scan so
|
|
// that bracketed IPv6 literals ("[::1]:5000") normalize to "::1" and match the
|
|
// form url.URL.Hostname() produces for the UI host.
|
|
func hostWithoutPort(h string) string {
|
|
if host, _, err := net.SplitHostPort(h); err == nil {
|
|
return host
|
|
}
|
|
return h
|
|
}
|
|
|
|
// primaryRegistryDomain returns the first registry domain, or empty string if none.
|
|
func primaryRegistryDomain(domains []string) string {
|
|
if len(domains) > 0 {
|
|
return domains[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DID returns the appview's did:web identity derived from its BaseURL.
|
|
func (s *AppViewServer) DID() string {
|
|
return DIDFromBaseURL(s.Config.Server.BaseURL)
|
|
}
|
|
|
|
// DIDFromBaseURL derives a did:web identifier from a base URL.
|
|
// Per the did:web spec, non-standard ports are percent-encoded.
|
|
// Examples:
|
|
//
|
|
// "https://atcr.io" → "did:web:atcr.io"
|
|
// "http://localhost:5000" → "did:web:localhost%3A5000"
|
|
func DIDFromBaseURL(baseURL string) string {
|
|
u, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "did:web:localhost"
|
|
}
|
|
|
|
hostname := u.Hostname()
|
|
if hostname == "" {
|
|
hostname = "localhost"
|
|
}
|
|
|
|
port := u.Port()
|
|
isStandardPort := (u.Scheme == "https" && port == "443") ||
|
|
(u.Scheme == "http" && port == "80") ||
|
|
port == ""
|
|
|
|
if isStandardPort {
|
|
return "did:web:" + hostname
|
|
}
|
|
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
|
|
}
|
|
|
|
// handleDIDDocument serves the appview's DID document at /.well-known/did.json.
|
|
// This is a service identity for key discovery — no PDS, no repo, no firehose.
|
|
// Holds use this to discover the appview's P-256 public key for JWT verification.
|
|
func (s *AppViewServer) handleDIDDocument(w http.ResponseWriter, r *http.Request) {
|
|
did := s.DID()
|
|
|
|
pubKey, err := s.OAuthKey.PublicKey()
|
|
if err != nil {
|
|
slog.Error("Failed to get public key for DID document", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
doc := map[string]any{
|
|
"@context": []string{
|
|
"https://www.w3.org/ns/did/v1",
|
|
"https://w3id.org/security/multikey/v1",
|
|
},
|
|
"id": did,
|
|
"verificationMethod": []map[string]any{
|
|
{
|
|
"id": did + "#appview",
|
|
"type": "Multikey",
|
|
"controller": did,
|
|
"publicKeyMultibase": pubKey.Multibase(),
|
|
},
|
|
},
|
|
"authentication": []string{
|
|
did + "#appview",
|
|
},
|
|
"assertionMethod": []string{
|
|
did + "#appview",
|
|
},
|
|
"service": []map[string]any{
|
|
{
|
|
"id": "#atcr_appview",
|
|
"type": "AtcrAppView",
|
|
"serviceEndpoint": s.Config.Server.BaseURL,
|
|
},
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/did+ld+json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
if err := json.NewEncoder(w).Encode(doc); err != nil {
|
|
slog.Error("Failed to encode DID document", "error", err)
|
|
}
|
|
}
|
|
|
|
// waitForLeasedWorkers gives the leased background workers a moment to stop and
|
|
// release their leases before the process exits.
|
|
//
|
|
// Skipping this does not lose data (every leased worker is restartable and the
|
|
// leases expire on their own), but it does mean the replacement instance waits
|
|
// out the full TTL before it can take over, so a rolling deploy pauses indexing
|
|
// for a minute instead of a second.
|
|
func (s *AppViewServer) waitForLeasedWorkers() {
|
|
if s.Leases == nil {
|
|
return
|
|
}
|
|
const leaseDrainTimeout = 5 * time.Second
|
|
slog.Info("Waiting for leased workers to release", "component", "leases", "timeout", leaseDrainTimeout)
|
|
s.Leases.Wait(leaseDrainTimeout)
|
|
}
|
|
|
|
// startCleanupWorker runs the periodic expiry sweep under the cleanup lease,
|
|
// after clearing the crew denial backoffs once on acquiring it.
|
|
//
|
|
// That clear used to run on every boot, unconditionally, as
|
|
// "DELETE FROM hold_crew_denials" with no scoping. The intent is sound: a
|
|
// restart usually means a fix has shipped, and users sitting on a backoff of up
|
|
// to an hour should get to retry immediately rather than wait it out. The
|
|
// implementation stopped being sound the moment there was more than one
|
|
// instance, because then a rolling deploy wipes the shared table once per
|
|
// instance and every scale-out event wipes it again.
|
|
//
|
|
// Moving it under the lease keeps the behavior (a deploy still clears the
|
|
// backoffs) while making it happen once. Note the in-memory half of
|
|
// ClearAllDenials was always a no-op at startup, since a fresh process has an
|
|
// empty map; the database wipe was the only thing it ever really did.
|
|
func (s *AppViewServer) startCleanupWorker(ctx context.Context) {
|
|
const cleanupInterval = time.Hour
|
|
authorizer := s.HoldAuthorizer
|
|
|
|
s.Leases.Go(ctx, db.LeaseCleanup, func(leaseCtx context.Context) error {
|
|
if remote, ok := authorizer.(*auth.RemoteHoldAuthorizer); ok {
|
|
if err := remote.ClearAllDenials(); err != nil {
|
|
// Not fatal: the denials carry their own next_retry_at and will
|
|
// lapse on their own.
|
|
slog.Warn("Failed to clear denial caches", "error", err)
|
|
}
|
|
}
|
|
return db.RunPeriodicCleanup(leaseCtx, s.Database, s.SessionStore, cleanupInterval)
|
|
})
|
|
}
|
|
|
|
// initializeJetstream initializes the Jetstream workers for real-time events and backfill.
|
|
//
|
|
// Both run under leases. The consumer must be a singleton for correctness:
|
|
// StatsCache is per-process in-memory state whose aggregate is written to
|
|
// repository_stats as an absolute value, so two consumers each write a partial
|
|
// sum as though it were the whole truth. The webhook dispatcher hangs off the
|
|
// same processor, so a second consumer also doubles every delivery. Backfill is
|
|
// leased because it is expensive and re-reads every user's PDS.
|
|
func (s *AppViewServer) initializeJetstream(ctx context.Context) {
|
|
jetstreamURLs := s.Config.Jetstream.URLs
|
|
|
|
// Explicitly empty URLs disables Jetstream. The YAML config always
|
|
// populates a default list, so the only way to reach this branch is to
|
|
// blank the slice in code (tests, embedded deployments). Without this
|
|
// check, NewWorker silently falls back to the public Bluesky endpoint.
|
|
if len(jetstreamURLs) == 0 {
|
|
slog.Info("Jetstream disabled (no URLs configured)", "component", "jetstream")
|
|
return
|
|
}
|
|
|
|
s.Leases.Go(ctx, db.LeaseJetstream, func(leaseCtx context.Context) error {
|
|
worker := jetstream.NewWorker(s.Database, jetstreamURLs, 0)
|
|
// Set webhook dispatcher on live worker (backfill skips dispatch)
|
|
if s.WebhookDispatcher != nil {
|
|
worker.Processor().SetWebhookDispatcher(s.WebhookDispatcher)
|
|
}
|
|
slog.Info("Jetstream real-time worker started", "component", "jetstream", "endpoints", len(jetstreamURLs))
|
|
// Returns only when leaseCtx is done: either shutdown, or the lease was
|
|
// lost and this instance must stop consuming before the new holder
|
|
// starts. A fresh worker is built on each acquisition so its in-memory
|
|
// StatsCache is never carried across a gap in which another instance
|
|
// was the one indexing.
|
|
worker.StartWithFailover(leaseCtx)
|
|
return leaseCtx.Err()
|
|
})
|
|
|
|
if s.Config.Jetstream.BackfillEnabled {
|
|
relayEndpoints := s.Config.Jetstream.RelayEndpoints
|
|
defaultHoldDID := s.Config.Server.PrimaryHoldDID()
|
|
testMode := s.Config.Server.TestMode
|
|
|
|
backfillWorker, err := jetstream.NewBackfillWorker(s.Database, relayEndpoints, defaultHoldDID, testMode, s.Refresher)
|
|
if err != nil {
|
|
slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err)
|
|
} else {
|
|
interval := s.Config.Jetstream.BackfillInterval
|
|
// The startup run and the periodic schedule were two goroutines on
|
|
// context.Background(). They are one leased worker now, so a single
|
|
// instance backfills and a shutdown actually stops it mid-run
|
|
// instead of letting it finish against a closing database.
|
|
s.Leases.Go(ctx, db.LeaseBackfill, func(leaseCtx context.Context) error {
|
|
const startupDelay = 5 * time.Second
|
|
slog.Info("Waiting for services to be ready", "component", "jetstream/backfill", "startup_delay", startupDelay)
|
|
select {
|
|
case <-leaseCtx.Done():
|
|
return leaseCtx.Err()
|
|
case <-time.After(startupDelay):
|
|
}
|
|
|
|
runBackfill := func(reason string) {
|
|
slog.Info("Starting backfill", "component", "jetstream/backfill", "reason", reason, "relay_endpoints", relayEndpoints)
|
|
if err := backfillWorker.Start(leaseCtx); err != nil {
|
|
slog.Warn("Backfill finished with error", "component", "jetstream/backfill", "reason", reason, "error", err)
|
|
} else {
|
|
slog.Info("Backfill completed successfully", "component", "jetstream/backfill", "reason", reason)
|
|
}
|
|
}
|
|
|
|
runBackfill("startup")
|
|
|
|
if interval <= 0 {
|
|
slog.Info("Periodic backfill disabled (interval=0), only startup backfill will run", "component", "jetstream/backfill")
|
|
// Hold the lease rather than returning: releasing it would
|
|
// let another instance acquire and run its own startup
|
|
// backfill, turning "once" into "once per instance".
|
|
<-leaseCtx.Done()
|
|
return leaseCtx.Err()
|
|
}
|
|
|
|
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-leaseCtx.Done():
|
|
return leaseCtx.Err()
|
|
case <-ticker.C:
|
|
runBackfill("periodic")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|