package labeler import ( "context" "database/sql" "fmt" "log/slog" "net" "net/http" "net/url" "os" "os/signal" "syscall" "time" "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/atproto/atcrypto" indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/go-chi/chi/v5" ) // Server is the labeler HTTP server. type Server struct { config *Config storage *LabelerDB db *sql.DB router chi.Router clientApp *indigooauth.ClientApp auth *Auth did string signingKey *atcrypto.PrivateKeyK256 hub *Hub } // NewServer creates a new labeler server. func NewServer(cfg *Config) (*Server, error) { storage, err := OpenDB(cfg.DBPath(), LibsqlSync{ SyncURL: cfg.Labeler.LibsqlSyncURL, AuthToken: cfg.Labeler.LibsqlAuthToken, SyncInterval: cfg.Labeler.LibsqlSyncInterval, }) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } ctx := context.Background() did, signingKey, err := LoadIdentity(ctx, cfg) if err != nil { _ = storage.Close() return nil, err } publicURL := cfg.PublicURL() // Set up OAuth client for admin login oauthStore := indigooauth.NewMemStore() scopes := []string{"atproto"} var oauthConfig indigooauth.ClientConfig var redirectURI string u, err := url.Parse(publicURL) if err != nil { return nil, fmt.Errorf("invalid public URL: %w", err) } host := u.Hostname() if isLocalhost(host) { port := u.Port() if port == "" { port = "5002" } oauthBaseURL := "http://127.0.0.1:" + port redirectURI = oauthBaseURL + "/auth/oauth/callback" oauthConfig = indigooauth.NewLocalhostConfig(redirectURI, scopes) } else { clientID := publicURL + "/oauth-client-metadata.json" redirectURI = publicURL + "/auth/oauth/callback" oauthConfig = indigooauth.NewPublicConfig(clientID, redirectURI, scopes) } clientApp := indigooauth.NewClientApp(&oauthConfig, oauthStore) clientApp.Dir = atproto.GetDirectory() auth := NewAuth(cfg.Labeler.OwnerDID) s := &Server{ config: cfg, storage: storage, db: storage.DB, clientApp: clientApp, auth: auth, did: did, signingKey: signingKey, hub: NewHub(), } s.setupRoutes() return s, nil } func (s *Server) setupRoutes() { r := chi.NewRouter() // DID document r.Get("/.well-known/did.json", s.handleDIDDocument) // OAuth client metadata r.Get("/oauth-client-metadata.json", s.handleClientMetadata) // Auth routes (public) r.Get("/auth/login", s.handleLogin) r.Get("/auth/oauth/authorize", s.handleAuthorize) r.Get("/auth/oauth/callback", s.handleCallback) r.Get("/auth/logout", s.handleLogout) // XRPC endpoints (public) r.Get("/xrpc/com.atproto.label.subscribeLabels", s.handleSubscribeLabels) r.Get("/xrpc/com.atproto.label.queryLabels", s.handleQueryLabels) // Protected routes (require owner). CSRF is enforced for state-mutating // methods inside the same group, so it sees the session on the context. r.Group(func(r chi.Router) { r.Use(s.auth.RequireOwner) r.Use(s.auth.RequireCSRF) r.Get("/", s.handleDashboard) r.Get("/takedown", s.handleTakedownForm) r.Post("/takedown", s.handleTakedownSubmit) r.Post("/reverse", s.handleReverse) }) s.router = r } // Serve starts the HTTP server with graceful shutdown. func (s *Server) Serve() error { slog.Info("Starting labeler service", "addr", s.config.Labeler.Addr, "public_url", s.config.PublicURL(), "did", s.did, "owner", s.config.Labeler.OwnerDID, ) srv := &http.Server{ Addr: s.config.Labeler.Addr, Handler: s.router, } // Graceful shutdown ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() errCh := make(chan error, 1) go func() { errCh <- srv.ListenAndServe() }() select { case err := <-errCh: if err != http.ErrServerClosed { return err } case <-ctx.Done(): slog.Info("Shutting down labeler service") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("shutdown error: %w", err) } } if err := s.storage.Close(); err != nil { slog.Warn("Error closing labeler database", "error", err) } return nil } // isLocalhost returns true when the host is reachable only from the local machine / // docker host — anything that an external PDS can't reach. Matches the hold's policy: // any IP literal counts (covers 127.0.0.1, 192.168.*, 172.16-31.*, 10.*, ::1, etc.) plus // the literal "localhost". When this is true, OAuth uses indigo's `NewLocalhostConfig` // which sets a `http://localhost`-form client_id that PDSes accept under the loopback // exception — so the PDS never has to fetch the client metadata URL we publish. func isLocalhost(host string) bool { if host == "localhost" { return true } return net.ParseIP(host) != nil }