coverage~bigbes/sr-ht-doltede3b0bbweb/handlers_internal.go

Coverage
78.4% 40/51 statements
Δ
Blob
cf88560
1 package web
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net/http"
8 "strings"
9
10 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
11 "sourcecraft.dev/bigbes/sr-ht-dolt/db"
12 )
13
14 // This file implements the service-to-service create endpoint that lets other
15 // SourceHut services provision a companion Dolt database. Its first (and only)
16 // caller is git.sr.ht's post-update hook, which POSTs here whenever a git repo
17 // is pushed so a matching Dolt DB exists at ~owner/name before the user's first
18 // `dolt push`. It is NOT a browser route: it carries no CSRF token and no
19 // unified-login cookie, and is guarded by sr-ht-ecore's internalauth (see the
20 // mount in router.go) instead of the cookie auth the rest of web/ uses.
21
22 // internalCreateRequest is the JSON body POSTed to /internal/repos. Owner is a
23 // SourceHut username (with or without the leading "~"); Name is the database
24 // name. Visibility is optional and defaults to PRIVATE — the safe default for
25 // an auto-provisioned companion the user has not explicitly published.
26 type internalCreateRequest struct {
27 Owner string `json:"owner"`
28 Name string `json:"name"`
29 Description string `json:"description"`
30 Visibility string `json:"visibility"`
31 }
32
33 // internalCreateResponse is returned on success. Created distinguishes a
34 // freshly provisioned database (201) from one that already existed (200), so
35 // the caller can decide whether to announce the companion to the user.
36 type internalCreateResponse struct {
37 URL string `json:"url"`
38 Created bool `json:"created"`
39 }
40
41 // handleInternalCreate provisions a Dolt database for owner/name. It mirrors
42 // handleCreate's insert-row-then-init-store ordering (so metadata and disk
43 // never diverge) but is idempotent: a repeated call for an existing companion
44 // returns 200 instead of an error, because the caller fires on every push and
45 // must not fail once the DB already exists.
46 8 func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
47 8 var req internalCreateRequest
48 8 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
49 0 http.Error(w, "malformed JSON body", http.StatusBadRequest)
50 0 return
51 0 }
52 8 req.Owner = strings.TrimPrefix(strings.TrimSpace(req.Owner), "~")
53 8 req.Name = strings.TrimSpace(req.Name)
54 8
55 8 if req.Owner == "" {
56 0 http.Error(w, "owner is required", http.StatusBadRequest)
57 0 return
58 0 }
59 8 if err := core.ValidateName(req.Name); err != nil {
60 1 http.Error(w, err.Error(), http.StatusBadRequest)
61 1 return
62 1 }
63
64 7 visibility := core.VisibilityPrivate
65 7 if req.Visibility != "" {
66 0 v, ok := parseVisibility(req.Visibility)
67 0 if !ok {
68 0 http.Error(w, "invalid visibility", http.StatusBadRequest)
69 0 return
70 0 }
71 0 visibility = v
72 }
73
74 7 ctx := r.Context()
75 7
76 7 // Resolve (and, on first sight, mirror) the owner's account so we have a
77 7 // local UserID to own the row. A permanent miss (no such meta user) is a
78 7 // client error, not a server fault.
79 7 caller, err := a.cfg.Users.LookupUser(ctx, req.Owner)
80 7 if err != nil {
81 1 http.Error(w, fmt.Sprintf("resolve owner %q: %v", req.Owner, err), http.StatusUnprocessableEntity)
82 1 return
83 1 }
84
85 // Mirror the git twin's description. The hook that calls us fires on every
86 // git push but its push context carries no description, so resolve it from
87 // git.sr.ht ourselves. Strictly best-effort: a miss (no twin, git.sr.ht
88 // unreachable, no resolver wired) only means no mirroring this time.
89 6 var (
90 6 gitDesc string
91 6 gitOK bool
92 6 )
93 6 if a.cfg.Git != nil {
94 3 gitDesc, gitOK = a.cfg.Git.Description(ctx, caller.Username, req.Name)
95 3 }
96 6 if req.Description == "" && gitOK {
97 3 req.Description = gitDesc
98 3 }
99
100 6 url := a.repoURL(caller.Username, req.Name)
101 6 diskPath := a.cfg.RepoDiskPath(caller.Username, req.Name)
102 6 repo := &core.Repo{
103 6 Name: req.Name,
104 6 Description: req.Description,
105 6 OwnerID: caller.UserID,
106 6 OwnerName: caller.Username,
107 6 Path: diskPath,
108 6 Visibility: visibility,
109 6 }
110 6
111 6 // Insert the metadata row first: a name collision (ErrNameTaken) means the
112 6 // companion already exists, which for this idempotent endpoint is success,
113 6 // not an error — return 200 without touching disk.
114 6 created, err := a.cfg.Repos.CreateRepo(ctx, repo)
115 6 if err != nil {
116 3 if errors.Is(err, db.ErrNameTaken) {
117 3 // The push is also the description sync point for an existing
118 3 // companion. Only a non-empty git description overwrites, so a
119 3 // twin with no description never clobbers one set in dolt's own
120 3 // settings; failures are swallowed like the rest of this path.
121 3 if gitOK && gitDesc != "" {
122 1 if existing, gerr := a.cfg.Repos.GetRepoByOwnerAndName(ctx, caller.Username, req.Name); gerr == nil && existing.Description != gitDesc {
123 1 _ = a.cfg.Repos.UpdateRepo(ctx, existing.ID, gitDesc, existing.Visibility)
124 1 }
125 }
126 3 writeJSON(w, http.StatusOK, internalCreateResponse{URL: url, Created: false})
127 3 return
128 }
129 0 http.Error(w, "create database", http.StatusInternalServerError)
130 0 return
131 }
132
133 // The companion is provisioned EMPTY — no branches, no "Initialize data
134 // repository" commit. This endpoint fires before its user has ever pushed,
135 // and whatever they push first (a beads tracker, a database built locally)
136 // has a history of its own. dolt decides fast-forward on the client, so an
137 // initial commit here would make every such first push a non-fast-forward
138 // the server cannot forgive — the user's only way in would be --force. An
139 // empty store lets that first push land as the database's initial history.
140 3 if err := a.cfg.Stores.InitEmptyStore(ctx, diskPath); err != nil {
141 1 // InitEmptyStore self-cleans its directory; undo the metadata row too so
142 1 // a failed provision leaves nothing behind and a retry can start clean.
143 1 _ = a.cfg.Repos.DeleteRepo(ctx, created.ID)
144 1 http.Error(w, "initialize database store", http.StatusInternalServerError)
145 1 return
146 1 }
147 2 writeJSON(w, http.StatusCreated, internalCreateResponse{URL: url, Created: true})
148 }
149
150 // repoURL builds the external web URL for a database, e.g.
151 // https://dolt.srht.bigb.es/~owner/name. The origin is the chrome's, resolved
152 // once at startup and already stripped of a trailing slash, so the URL this
153 // hands back to git.sr.ht is the same one the pages link to.
154 6 func (a *app) repoURL(owner, name string) string {
155 6 return fmt.Sprintf("%s/~%s/%s", a.chrome.SelfOrigin(), owner, name)
156 6 }
157
158 // writeJSON writes v as a JSON response with the given status.
159 5 func writeJSON(w http.ResponseWriter, status int, v any) {
160 5 w.Header().Set("Content-Type", "application/json")
161 5 w.WriteHeader(status)
162 5 _ = json.NewEncoder(w).Encode(v)
163 5 }