mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-24 08:51:30 +00:00
191 lines
4.8 KiB
Go
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
|
|
}
|