Files

191 lines
4.8 KiB
Go

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
}