package web
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
// This file implements the service-to-service create endpoint that lets other
// SourceHut services provision a companion Dolt database. Its first (and only)
// caller is git.sr.ht's post-update hook, which POSTs here whenever a git repo
// is pushed so a matching Dolt DB exists at ~owner/name before the user's first
// `dolt push`. It is NOT a browser route: it carries no CSRF token and no
// unified-login cookie, and is guarded by internalAuthGuard instead of the
// cookie auth the rest of web/ uses.
// internalCreateRequest is the JSON body POSTed to /internal/repos. Owner is a
// SourceHut username (with or without the leading "~"); Name is the database
// name. Visibility is optional and defaults to PRIVATE — the safe default for
// an auto-provisioned companion the user has not explicitly published.
type internalCreateRequest struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
}
// internalCreateResponse is returned on success. Created distinguishes a
// freshly provisioned database (201) from one that already existed (200), so
// the caller can decide whether to announce the companion to the user.
type internalCreateResponse struct {
URL string `json:"url"`
Created bool `json:"created"`
}
// internalAuthGuard authenticates a service-to-service request the same way
// core-go's auth.internalAuth does — the source IP must fall inside
// [sr.ht]internal-ipnet AND the Authorization header must be a valid,
// unexpired "Internal <fernet-token>" minted with the shared [sr.ht]network-key.
// The network-key check is the real guard (only internal services hold it); the
// IP check is defense-in-depth against the endpoint being reachable through the
// public Traefik route. We reimplement rather than reuse auth.Middleware because
// that middleware 401s any request without a cookie/bearer and pulls in the
// full user-resolution path we do not need here — the owner comes from the body.
func internalAuthGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil || !config.IsInternalIP(ip) {
http.Error(w, "internal auth: source IP not permitted", http.StatusUnauthorized)
return
}
parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "internal") {
http.Error(w, "internal auth: Internal authorization required", http.StatusUnauthorized)
return
}
payload := crypto.DecryptWithExpiration([]byte(parts[1]), 30*time.Second)
if payload == nil {
http.Error(w, "internal auth: invalid or expired token", http.StatusForbidden)
return
}
var ia struct {
ClientID string `json:"client_id"`
NodeID string `json:"node_id"`
}
if err := json.Unmarshal(payload, &ia); err != nil || ia.ClientID == "" || ia.NodeID == "" {
http.Error(w, "internal auth: malformed token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// handleInternalCreate provisions a Dolt database for owner/name. It mirrors
// handleCreate's insert-row-then-init-store ordering (so metadata and disk
// never diverge) but is idempotent: a repeated call for an existing companion
// returns 200 instead of an error, because the caller fires on every push and
// must not fail once the DB already exists.
func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
var req internalCreateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
http.Error(w, "malformed JSON body", http.StatusBadRequest)
return
}
req.Owner = strings.TrimPrefix(strings.TrimSpace(req.Owner), "~")
req.Name = strings.TrimSpace(req.Name)
if req.Owner == "" {
http.Error(w, "owner is required", http.StatusBadRequest)
return
}
if err := core.ValidateName(req.Name); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
visibility := core.VisibilityPrivate
if req.Visibility != "" {
v, ok := parseVisibility(req.Visibility)
if !ok {
http.Error(w, "invalid visibility", http.StatusBadRequest)
return
}
visibility = v
}
ctx := r.Context()
// Resolve (and, on first sight, mirror) the owner's account so we have a
// local UserID to own the row. A permanent miss (no such meta user) is a
// client error, not a server fault.
caller, err := a.cfg.Users.LookupUser(ctx, req.Owner)
if err != nil {
http.Error(w, fmt.Sprintf("resolve owner %q: %v", req.Owner, err), http.StatusUnprocessableEntity)
return
}
// Mirror the git twin's description. The hook that calls us fires on every
// git push but its push context carries no description, so resolve it from
// git.sr.ht ourselves. Strictly best-effort: a miss (no twin, git.sr.ht
// unreachable, no resolver wired) only means no mirroring this time.
var (
gitDesc string
gitOK bool
)
if a.cfg.Git != nil {
gitDesc, gitOK = a.cfg.Git.Description(ctx, caller.Username, req.Name)
}
if req.Description == "" && gitOK {
req.Description = gitDesc
}
url := a.repoURL(caller.Username, req.Name)
diskPath := a.cfg.RepoDiskPath(caller.Username, req.Name)
repo := &core.Repo{
Name: req.Name,
Description: req.Description,
OwnerID: caller.UserID,
OwnerName: caller.Username,
Path: diskPath,
Visibility: visibility,
}
// Insert the metadata row first: a name collision (ErrNameTaken) means the
// companion already exists, which for this idempotent endpoint is success,
// not an error — return 200 without touching disk.
created, err := a.cfg.Repos.CreateRepo(ctx, repo)
if err != nil {
if errors.Is(err, db.ErrNameTaken) {
// The push is also the description sync point for an existing
// companion. Only a non-empty git description overwrites, so a
// twin with no description never clobbers one set in dolt's own
// settings; failures are swallowed like the rest of this path.
if gitOK && gitDesc != "" {
if existing, gerr := a.cfg.Repos.GetRepoByOwnerAndName(ctx, caller.Username, req.Name); gerr == nil && existing.Description != gitDesc {
_ = a.cfg.Repos.UpdateRepo(ctx, existing.ID, gitDesc, existing.Visibility)
}
}
writeJSON(w, http.StatusOK, internalCreateResponse{URL: url, Created: false})
return
}
http.Error(w, "create database", http.StatusInternalServerError)
return
}
// The initial empty commit's author is cosmetic (real pushes overwrite
// history); mirror handleCreate and author it as the instance owner,
// falling back to the database owner.
authorName, authorEmail := config.GetOwner(a.cfg.Conf)
if authorName == "" {
authorName = caller.Username
}
if authorEmail == "" {
authorEmail = caller.Username + "@" + hostOf(config.GetOrigin(a.cfg.Conf, serviceName, true))
}
if err := a.cfg.Stores.InitStore(ctx, diskPath, authorName, authorEmail); err != nil {
// InitStore self-cleans its directory; undo the metadata row too so a
// failed provision leaves nothing behind and a retry can start clean.
_ = a.cfg.Repos.DeleteRepo(ctx, created.ID)
http.Error(w, "initialize database store", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, internalCreateResponse{URL: url, Created: true})
}
// repoURL builds the external web URL for a database, e.g.
// https://dolt.srht.bigb.es/~owner/name.
func (a *app) repoURL(owner, name string) string {
origin := strings.TrimRight(config.GetOrigin(a.cfg.Conf, serviceName, true), "/")
return fmt.Sprintf("%s/~%s/%s", origin, owner, name)
}
// hostOf returns the host authority of a URL, or the input unchanged if it does
// not parse as one (used only to synthesize a cosmetic commit-author email).
func hostOf(origin string) string {
if i := strings.Index(origin, "://"); i >= 0 {
origin = origin[i+3:]
}
if i := strings.IndexByte(origin, '/'); i >= 0 {
origin = origin[:i]
}
if origin == "" {
return "localhost"
}
return origin
}
// writeJSON writes v as a JSON response with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}