package web import ( "encoding/json" "errors" "fmt" "net/http" "strings" "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 sr-ht-ecore's internalauth (see the // mount in router.go) 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"` } // 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 companion is provisioned EMPTY — no branches, no "Initialize data // repository" commit. This endpoint fires before its user has ever pushed, // and whatever they push first (a beads tracker, a database built locally) // has a history of its own. dolt decides fast-forward on the client, so an // initial commit here would make every such first push a non-fast-forward // the server cannot forgive — the user's only way in would be --force. An // empty store lets that first push land as the database's initial history. if err := a.cfg.Stores.InitEmptyStore(ctx, diskPath); err != nil { // InitEmptyStore 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. The origin is the chrome's, resolved // once at startup and already stripped of a trailing slash, so the URL this // hands back to git.sr.ht is the same one the pages link to. func (a *app) repoURL(owner, name string) string { return fmt.Sprintf("%s/~%s/%s", a.chrome.SelfOrigin(), owner, name) } // 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) }