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 " 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) }