package remoteapi
import (
"context"
"database/sql"
"fmt"
"net"
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
"github.com/dolthub/dolt/go/libraries/doltcore/remotesrv"
"github.com/dolthub/dolt/go/libraries/utils/filesys"
"github.com/sirupsen/logrus"
"github.com/vaughan0/go-ini"
"go.bigb.es/sourcehut-dolt/db"
"go.bigb.es/sourcehut-dolt/storage"
)
// serviceName is the SourceHut service identifier for dolt.sr.ht. It selects
// the config section and OAuth grant namespace used across the auth stack.
const serviceName = "dolt.sr.ht"
// Config configures the remotesapi server assembly. It is the single struct the
// main wiring populates for both New (the chunk-store server) and NewCredServer
// (the credentials server); each uses the subset it needs.
type Config struct {
// Conf is the loaded instance config (the shared config.ini as ini.File),
// threaded into request contexts so the auth resolvers can read
// [webhooks]private-key, network-key, meta origin, etc.
Conf ini.File
// DB is the shared Postgres pool.
DB *sql.DB
// ReposRoot is the absolute directory under which bare NBS stores live
// ("<ReposRoot>/~<owner>/<name>"). The remotesrv filesys is rooted here.
ReposRoot string
// ListenAddr is the host:port for the remotesapi (gRPC + HTTP chunk data
// plane multiplexed on one h2c port).
ListenAddr string
// CredsListenAddr is the host:port for the separate CredentialsService
// (WhoAmI) gRPC server.
CredsListenAddr string
// HttpHost is the authority the server stamps into sealed chunk-download
// URLs (e.g. "dolt.srht.bigb.es"; may carry a port for local testing, e.g.
// "127.0.0.1:5306"). It also seeds the expected JWT audience: dolt's client
// derives the audience with net.SplitHostPort(endpoint), i.e. the bare host
// with any port stripped, so the audience the server must expect is
// normalizeAud(HttpHost). Leave empty to echo the request :authority into
// chunk URLs (auth then cannot be host-checked; used only by tests without a
// stable host).
HttpHost string
// Logger is the base logger; may be nil (a default is used).
Logger *logrus.Entry
}
// Server is the assembled remotesapi server: the remotesrv chunk-store server
// plus the storage cache it serves from. The cache is exposed so the web delete
// flow can evict a store when its repository is removed.
type Server struct {
srv *remotesrv.Server
cache *storage.Cache
logger *logrus.Entry
addr string
}
// New assembles the remotesapi chunk-store server: a db-backed repo lookup, the
// storage cache, our auth/authz interceptors, and the importable remotesrv
// server bound to a single h2c port. It does not start listening; call Serve.
func New(cfg Config) (*Server, error) {
if cfg.DB == nil {
return nil, fmt.Errorf("remoteapi: New requires a non-nil DB")
}
if cfg.ReposRoot == "" {
return nil, fmt.Errorf("remoteapi: New requires a ReposRoot")
}
logger := cfg.Logger
if logger == nil {
logger = logrus.NewEntry(logrus.StandardLogger())
}
// Repo lookup: resolve owner/name to the absolute on-disk store dir via the
// repository row. v1 has no push-to-create, so a missing row is an error the
// cache propagates (mapped to gRPC NotFound at the interceptor layer, which
// runs first anyway).
lookup := func(ctx context.Context, owner, name string) (string, error) {
repo, err := db.NewStore(cfg.DB).GetRepoByOwnerAndName(ctx, owner, name)
if err != nil {
return "", err
}
return repo.Path, nil
}
cache := storage.NewCache(lookup)
keys := newKeyStore(cfg.DB)
icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, logger)
// Load-bearing (see storage/init.go): the FS MUST be rooted at ReposRoot via
// LocalFilesysWithWorkingDir so sealed chunk-download URLs carry clean
// relative prefixes; a bare LocalFS breaks every clone/push at chunk
// transfer.
fs, err := filesys.LocalFilesysWithWorkingDir(cfg.ReposRoot)
if err != nil {
return nil, fmt.Errorf("remoteapi: root filesys at %q: %w", cfg.ReposRoot, err)
}
srv, err := remotesrv.NewServer(remotesrv.ServerArgs{
Logger: logger,
HttpHost: cfg.HttpHost,
HttpListenAddr: cfg.ListenAddr,
GrpcListenAddr: cfg.ListenAddr, // == HttpListenAddr ⇒ single h2c port
FS: fs,
DBCache: cache,
ReadOnly: false,
Options: icept.Options(),
ConcurrencyControl: remotesapi.PushConcurrencyControl_PUSH_CONCURRENCY_CONTROL_IGNORE_WORKING_SET,
})
if err != nil {
if cerr := cache.Close(); cerr != nil {
logger.Warnf("remoteapi: closing cache after NewServer failure: %v", cerr)
}
return nil, fmt.Errorf("remoteapi: remotesrv.NewServer: %w", err)
}
return &Server{srv: srv, cache: cache, logger: logger, addr: cfg.ListenAddr}, nil
}
// Cache returns the storage cache backing this server so the web delete flow
// can evict a store on repository removal.
func (s *Server) Cache() *storage.Cache { return s.cache }
// Serve binds the listeners and serves until GracefulStop. It blocks. It
// returns an error only if binding the listeners fails; the underlying
// remotesrv.Serve blocks until shutdown and does not return an error.
func (s *Server) Serve() error {
listeners, err := s.srv.Listeners()
if err != nil {
return fmt.Errorf("remoteapi: bind %q: %w", s.addr, err)
}
s.srv.Serve(listeners)
return nil
}
// GracefulStop stops the server and closes every memoized chunk store.
func (s *Server) GracefulStop() {
s.srv.GracefulStop()
if err := s.cache.Close(); err != nil {
s.logger.Warnf("remoteapi: closing cache on shutdown: %v", err)
}
}
// normalizeAud reduces a configured HttpHost to the bare host the dolt client
// puts in a JWT audience. Verified against dolt's grpc_dial_provider:
// getHostFromEndpoint(endpoint) calls net.SplitHostPort and returns the host
// with any port stripped, so the audience the server receives is always the
// bare host. Normalizing here lets operators write either "dolt.srht.bigb.es"
// or "dolt.srht.bigb.es:443" (or a "127.0.0.1:PORT" test host) and get the same
// expected audience. An empty host yields an empty audience (no keypair auth).
func normalizeAud(host string) string {
if host == "" {
return ""
}
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}