coverage~bigbes/sr-ht-dolt3523280cweb/handlers_repo.go

Coverage
80.6% 79/98 statements
Δ
+0.0
Blob
3dd11d5
1 package web
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "net/http"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11 "sourcecraft.dev/bigbes/sr-ht-core/config"
12
13 "go.bigb.es/auxilia/scribe"
14
15 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
16 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
17
18 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
19 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
20 "sourcecraft.dev/bigbes/sr-ht-dolt/db"
21 )
22
23 // overviewCommitLimit is how many recent commits the database overview shows.
24 const overviewCommitLimit = 10
25
26 // handleIndex renders the dashboard: the signed-in user's databases (owned +
27 // ACL) with a create link, or an anonymous welcome blurb.
28 11 func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
29 11 ac, caller := callerOf(r.Context())
30 11
31 11 view := struct {
32 11 chrome.Page
33 11 Repos chrome.RepoList
34 11 }{Page: a.page(r, serviceName), Repos: repoList(nil)}
35 11
36 11 if ac != nil {
37 4 repos, err := a.cfg.Repos.ListReposForDashboard(r.Context(), caller.UserID)
38 4 if err != nil {
39 0 http.Error(w, "failed to list databases", http.StatusInternalServerError)
40 0 return
41 0 }
42 4 view.Repos = repoList(repos)
43 }
44 11 a.render(w, http.StatusOK, "index", view)
45 }
46
47 // handleCreateForm renders the new-database form. Login is required.
48 2 func (a *app) handleCreateForm(w http.ResponseWriter, r *http.Request) {
49 2 if ac := a.requireLogin(w, r); ac == nil {
50 1 return
51 1 }
52 1 a.renderCreate(w, r, http.StatusOK, createForm{Visibility: string(core.VisibilityPublic)}, "")
53 }
54
55 // createForm is the create page's sticky form state.
56 type createForm struct {
57 Name string
58 Description string
59 Visibility string
60 // Initialize asks for an "Initialize data repository" commit in the new
61 // store. It defaults to OFF: an initial commit makes the first push from a
62 // database with a history of its own a non-fast-forward (dolt decides that
63 // on the client), so it would have to be forced. On costs the opposite —
64 // the database is clonable immediately, which an empty store is not.
65 Initialize bool
66 }
67
68 2 func (a *app) renderCreate(w http.ResponseWriter, r *http.Request, status int, form createForm, errMsg string) {
69 2 view := struct {
70 2 chrome.Page
71 2 Form createForm
72 2 Error string
73 2 }{
74 2 Page: a.page(r, "Create database — "+serviceName),
75 2 Form: form,
76 2 Error: errMsg,
77 2 }
78 2 a.render(w, status, "create", view)
79 2 }
80
81 // handleCreate processes the new-database form. It validates the name, creates
82 // the metadata row, then the on-disk store; on store-init failure it removes the
83 // just-created row so no orphan metadata survives. Login is required; the
84 // same-origin check is the router's (csrf.Require) and has already run.
85 5 func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) {
86 5 ac := a.requireLogin(w, r)
87 5 if ac == nil {
88 0 return
89 0 }
90 5 values, err := pages.FormValues(w, r, 0)
91 5 if err != nil {
92 0 a.renderCreate(w, r, http.StatusBadRequest, createForm{}, "Malformed form submission.")
93 0 return
94 0 }
95
96 5 form := createForm{
97 5 Name: strings.TrimSpace(values.Get("name")),
98 5 Description: strings.TrimSpace(values.Get("description")),
99 5 Visibility: values.Get("visibility"),
100 5 // An unchecked checkbox is simply absent from the submission, so any
101 5 // value at all means checked.
102 5 Initialize: values.Get("initialize") != "",
103 5 }
104 5
105 5 visibility, ok := parseVisibility(form.Visibility)
106 5 if !ok {
107 0 a.renderCreate(w, r, http.StatusBadRequest, form, "Invalid visibility.")
108 0 return
109 0 }
110 5 if err := core.ValidateName(form.Name); err != nil {
111 1 a.renderCreate(w, r, http.StatusBadRequest, form, err.Error())
112 1 return
113 1 }
114
115 4 owner := ac.Username
116 4 diskPath := a.cfg.RepoDiskPath(owner, form.Name)
117 4 repo := &core.Repo{
118 4 Name: form.Name,
119 4 Description: form.Description,
120 4 OwnerID: ac.UserID,
121 4 OwnerName: owner,
122 4 Path: diskPath,
123 4 Visibility: visibility,
124 4 }
125 4
126 4 // Insert the metadata row first: a name collision (ErrNameTaken) is caught
127 4 // before we ever touch disk. Then create the on-disk store; if that fails,
128 4 // remove the row we just inserted so metadata and disk never diverge.
129 4 created, err := a.cfg.Repos.CreateRepo(r.Context(), repo)
130 4 if err != nil {
131 0 if errors.Is(err, db.ErrNameTaken) {
132 0 a.renderCreate(w, r, http.StatusConflict, form,
133 0 "You already have a database with that name.")
134 0 return
135 0 }
136 0 http.Error(w, "failed to create database", http.StatusInternalServerError)
137 0 return
138 }
139
140 // Empty by default, so the first push from a database that already has a
141 // history lands as this one's initial history rather than being rejected as
142 // a non-fast-forward. The checkbox buys the opposite trade: an initial
143 // commit, and with it a database that can be cloned before anything is
144 // pushed to it.
145 4 if err := a.initStore(r.Context(), diskPath, ac, form.Initialize); err != nil {
146 2 // Both init paths self-clean their directory; undo the metadata row too.
147 2 _ = a.cfg.Repos.DeleteRepo(r.Context(), created.ID)
148 2 http.Error(w, "failed to initialize database store", http.StatusInternalServerError)
149 2 return
150 2 }
151
152 2 http.Redirect(w, r, "/~"+owner+"/"+form.Name, http.StatusSeeOther)
153 }
154
155 // initStore materializes the on-disk store for a newly created database. With
156 // initialize false — the default — it writes a store with no commits at all, so
157 // the owner's first push is a fast-forward from empty. With initialize true it
158 // writes the "Initialize data repository" commit, whose author is cosmetic
159 // (real pushes overwrite it): the instance owner from the config, falling back
160 // to the creating user's own name and address.
161 4 func (a *app) initStore(ctx context.Context, diskPath string, ac *authContext, initialize bool) error {
162 4 if !initialize {
163 2 return a.cfg.Stores.InitEmptyStore(ctx, diskPath)
164 2 }
165 2 ownerName, ownerEmail := config.GetOwner(a.cfg.Conf)
166 2 if ownerName == "" {
167 0 ownerName = ac.Username
168 0 }
169 2 if ownerEmail == "" {
170 0 ownerEmail = ac.Email
171 0 }
172 2 return a.cfg.Stores.InitStore(ctx, diskPath, ownerName, ownerEmail)
173 }
174
175 // handleUser renders a single user's visible databases (~user listing).
176 1 func (a *app) handleUser(w http.ResponseWriter, r *http.Request) {
177 1 owner := chi.URLParam(r, "user")
178 1 _, caller := callerOf(r.Context())
179 1
180 1 repos, err := a.cfg.Repos.ListReposByOwner(r.Context(), owner, caller)
181 1 if err != nil {
182 0 http.Error(w, "failed to list databases", http.StatusInternalServerError)
183 0 return
184 0 }
185
186 1 view := struct {
187 1 chrome.Page
188 1 Owner string
189 1 Repos chrome.RepoList
190 1 }{
191 1 Page: a.page(r, "~"+owner+" — "+serviceName),
192 1 Owner: owner,
193 1 Repos: repoList(repos),
194 1 }
195 1 a.render(w, http.StatusOK, "user", view)
196 }
197
198 // repoList adapts our databases to the shared listing partial
199 // ("srht-repo-list"), which every custom service on the instance renders its
200 // projects through. The Href and Title are the only service-specific part: a
201 // database lives at /~owner/name and is named for it, exactly as a repository
202 // is on git.sr.ht.
203 16 func repoList(repos []*core.Repo) chrome.RepoList {
204 16 items := make([]chrome.ListItem, 0, len(repos))
205 16 for _, repo := range repos {
206 3 path := "/~" + repo.OwnerName + "/" + repo.Name
207 3 items = append(items, chrome.ListItem{
208 3 Href: path,
209 3 Title: path[1:],
210 3 Visibility: string(repo.Visibility),
211 3 Description: repo.Description,
212 3 })
213 3 }
214 16 return chrome.RepoList{Items: items, Empty: "No databases yet."}
215 }
216
217 // handleOverview renders the database overview: description, visibility badge,
218 // branch list, latest commits, and a clone box showing both auth flows.
219 17 func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
220 17 repo, _, _, ok := a.loadRepoForBrowse(w, r)
221 17 if !ok {
222 8 return
223 8 }
224
225 9 var (
226 9 branches []browse.Branch
227 9 defBr string
228 9 commits []browse.CommitInfo
229 9 views []View
230 9 // browseFailed says only that the history could not be read. It used to
231 9 // be the browse layer's own error text, rendered into the page: that
232 9 // string names dolt internals and the store's on-disk path, which
233 9 // nothing else on this surface discloses and which a reader can do
234 9 // nothing with. A bool rather than a message, so no error can reach the
235 9 // template by being assigned to it later.
236 9 browseFailed bool
237 9 )
238 9 // browseFailure records the reason where it belongs — the operator's log,
239 9 // against the database it happened to — and leaves the page a fixed sentence.
240 9 browseFailure := func(what string, err error) {
241 3 browseFailed = true
242 3 slog.Warn(what, "component", "web", "database", repo.ID, scribe.Err(err))
243 3 }
244
245 9 if sess, err := a.cfg.Browse.Open(r.Context(), repo.Path); err == nil {
246 7 defer sess.Close()
247 7 if bs, err := sess.Branches(r.Context()); err == nil {
248 7 branches = bs
249 7 defBr = browse.DefaultBranch(bs)
250 7 if defBr != "" {
251 3 if cs, _, err := sess.Log(r.Context(), defBr, "", overviewCommitLimit); err == nil {
252 2 commits = cs
253 2 } else {
254 1 browseFailure("reading a database's log for the overview failed", err)
255 1 }
256 // Fingerprint the tables at the default branch to compute the
257 // optional alternative-view tabs. A browse failure here must not
258 // break the overview: on error we simply yield no view tabs.
259 3 if tables, err := sess.Tables(r.Context(), defBr); err == nil {
260 3 views = applicableViews(a.views, tables)
261 3 }
262 }
263 0 } else {
264 0 browseFailure("listing a database's branches for the overview failed", err)
265 0 }
266 2 } else {
267 2 browseFailure("opening a database for the overview failed", err)
268 2 }
269
270 9 view := struct {
271 9 chrome.Page
272 9 Repo *core.Repo
273 9 Branches []browse.Branch
274 9 DefaultBranch string
275 9 Commits []browse.CommitInfo
276 9 Views []View
277 9 CloneURL string
278 9 BrowseFailed bool
279 9 Empty bool
280 9 }{
281 9 Page: a.page(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName),
282 9 Repo: repo,
283 9 Branches: branches,
284 9 DefaultBranch: defBr,
285 9 Commits: commits,
286 9 Views: views,
287 9 CloneURL: a.cloneURL(repo),
288 9 BrowseFailed: browseFailed,
289 9 // A database nothing has been pushed to yet: it has no branches and the
290 9 // store read fine, so the emptiness is the answer rather than a symptom.
291 9 // The page then teaches push instead of clone — a store with no commits
292 9 // cannot be cloned at all, dolt refuses it as "contains no Dolt data".
293 9 // A browse failure is deliberately NOT empty: an unreadable store must
294 9 // not be advertised as a fresh one waiting for its first push.
295 9 Empty: !browseFailed && len(branches) == 0,
296 9 }
297 9 a.render(w, http.StatusOK, "overview", view)
298 }
299
300 // cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. The
301 // origin is the chrome's, resolved once at startup from our config section, so
302 // a clone box and a nav link can never quote two different hosts for us.
303 9 func (a *app) cloneURL(repo *core.Repo) string {
304 9 return a.chrome.SelfOrigin() + "/~" + repo.OwnerName + "/" + repo.Name
305 9 }
306
307 // requireLogin returns the authenticated caller, or nil after redirecting an
308 // anonymous request to the login page.
309 39 func (a *app) requireLogin(w http.ResponseWriter, r *http.Request) *authContext {
310 39 ac, _ := callerOf(r.Context())
311 39 if ac == nil {
312 2 a.redirectLogin(w, r)
313 2 return nil
314 2 }
315 37 return ac
316 }
317
318 // parseVisibility validates and maps a form visibility string.
319 6 func parseVisibility(s string) (core.Visibility, bool) {
320 6 switch core.Visibility(s) {
321 5 case core.VisibilityPublic:
322 5 return core.VisibilityPublic, true
323 0 case core.VisibilityUnlisted:
324 0 return core.VisibilityUnlisted, true
325 1 case core.VisibilityPrivate:
326 1 return core.VisibilityPrivate, true
327 0 default:
328 0 return "", false
329 }
330 }