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