Monorepo for Tangled
tangled.org
1package repo
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "slices"
8 "strings"
9 "time"
10
11 "tangled.org/core/api/tangled"
12 "tangled.org/core/appview/db"
13 "tangled.org/core/appview/models"
14 "tangled.org/core/appview/oauth"
15 "tangled.org/core/appview/pages"
16 xrpcclient "tangled.org/core/appview/xrpcclient"
17 "tangled.org/core/types"
18
19 comatproto "github.com/bluesky-social/indigo/api/atproto"
20 lexutil "github.com/bluesky-social/indigo/lex/util"
21 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22)
23
24type tab = map[string]any
25
26var (
27 // would be great to have ordered maps right about now
28 settingsTabs []tab = []tab{
29 {"Name": "general", "Icon": "sliders-horizontal"},
30 {"Name": "access", "Icon": "users"},
31 {"Name": "pipelines", "Icon": "layers-2"},
32 }
33)
34
35func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
36 l := rp.logger.With("handler", "SetDefaultBranch")
37
38 f, err := rp.repoResolver.Resolve(r)
39 if err != nil {
40 l.Error("failed to get repo and knot", "err", err)
41 return
42 }
43
44 noticeId := "operation-error"
45 branch := r.FormValue("branch")
46 if branch == "" {
47 http.Error(w, "malformed form", http.StatusBadRequest)
48 return
49 }
50
51 client, err := rp.oauth.ServiceClient(
52 r,
53 oauth.WithService(f.Knot),
54 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
55 oauth.WithDev(rp.config.Core.Dev),
56 )
57 if err != nil {
58 l.Error("failed to connect to knot server", "err", err)
59 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
60 return
61 }
62
63 xe := tangled.RepoSetDefaultBranch(
64 r.Context(),
65 client,
66 &tangled.RepoSetDefaultBranch_Input{
67 Repo: f.RepoAt().String(),
68 DefaultBranch: branch,
69 },
70 )
71 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
72 l.Error("xrpc failed", "err", xe)
73 rp.pages.Notice(w, noticeId, err.Error())
74 return
75 }
76
77 rp.pages.HxRefresh(w)
78}
79
80func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
81 user := rp.oauth.GetUser(r)
82 l := rp.logger.With("handler", "Secrets")
83 l = l.With("did", user.Did)
84
85 f, err := rp.repoResolver.Resolve(r)
86 if err != nil {
87 l.Error("failed to get repo and knot", "err", err)
88 return
89 }
90
91 if f.Spindle == "" {
92 l.Error("empty spindle cannot add/rm secret", "err", err)
93 return
94 }
95
96 lxm := tangled.RepoAddSecretNSID
97 if r.Method == http.MethodDelete {
98 lxm = tangled.RepoRemoveSecretNSID
99 }
100
101 spindleClient, err := rp.oauth.ServiceClient(
102 r,
103 oauth.WithService(f.Spindle),
104 oauth.WithLxm(lxm),
105 oauth.WithExp(60),
106 oauth.WithDev(rp.config.Core.Dev),
107 )
108 if err != nil {
109 l.Error("failed to create spindle client", "err", err)
110 return
111 }
112
113 key := r.FormValue("key")
114 if key == "" {
115 w.WriteHeader(http.StatusBadRequest)
116 return
117 }
118
119 switch r.Method {
120 case http.MethodPut:
121 errorId := "add-secret-error"
122
123 value := r.FormValue("value")
124 if value == "" {
125 w.WriteHeader(http.StatusBadRequest)
126 return
127 }
128
129 err = tangled.RepoAddSecret(
130 r.Context(),
131 spindleClient,
132 &tangled.RepoAddSecret_Input{
133 Repo: f.RepoAt().String(),
134 Key: key,
135 Value: value,
136 },
137 )
138 if err != nil {
139 l.Error("Failed to add secret.", "err", err)
140 rp.pages.Notice(w, errorId, "Failed to add secret.")
141 return
142 }
143
144 case http.MethodDelete:
145 errorId := "operation-error"
146
147 err = tangled.RepoRemoveSecret(
148 r.Context(),
149 spindleClient,
150 &tangled.RepoRemoveSecret_Input{
151 Repo: f.RepoAt().String(),
152 Key: key,
153 },
154 )
155 if err != nil {
156 l.Error("Failed to delete secret.", "err", err)
157 rp.pages.Notice(w, errorId, "Failed to delete secret.")
158 return
159 }
160 }
161
162 rp.pages.HxRefresh(w)
163}
164
165func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
166 tabVal := r.URL.Query().Get("tab")
167 if tabVal == "" {
168 tabVal = "general"
169 }
170
171 switch tabVal {
172 case "general":
173 rp.generalSettings(w, r)
174
175 case "access":
176 rp.accessSettings(w, r)
177
178 case "pipelines":
179 rp.pipelineSettings(w, r)
180 }
181}
182
183func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
184 l := rp.logger.With("handler", "generalSettings")
185
186 f, err := rp.repoResolver.Resolve(r)
187 user := rp.oauth.GetUser(r)
188
189 scheme := "http"
190 if !rp.config.Core.Dev {
191 scheme = "https"
192 }
193 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
194 xrpcc := &indigoxrpc.Client{
195 Host: host,
196 }
197
198 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
199 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
200 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
201 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
202 rp.pages.Error503(w)
203 return
204 }
205
206 var result types.RepoBranchesResponse
207 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
208 l.Error("failed to decode XRPC response", "err", err)
209 rp.pages.Error503(w)
210 return
211 }
212
213 defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
214 if err != nil {
215 l.Error("failed to fetch labels", "err", err)
216 rp.pages.Error503(w)
217 return
218 }
219
220 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Labels))
221 if err != nil {
222 l.Error("failed to fetch labels", "err", err)
223 rp.pages.Error503(w)
224 return
225 }
226 // remove default labels from the labels list, if present
227 defaultLabelMap := make(map[string]bool)
228 for _, dl := range defaultLabels {
229 defaultLabelMap[dl.AtUri().String()] = true
230 }
231 n := 0
232 for _, l := range labels {
233 if !defaultLabelMap[l.AtUri().String()] {
234 labels[n] = l
235 n++
236 }
237 }
238 labels = labels[:n]
239
240 subscribedLabels := make(map[string]struct{})
241 for _, l := range f.Labels {
242 subscribedLabels[l] = struct{}{}
243 }
244
245 // if there is atleast 1 unsubbed default label, show the "subscribe all" button,
246 // if all default labels are subbed, show the "unsubscribe all" button
247 shouldSubscribeAll := false
248 for _, dl := range defaultLabels {
249 if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
250 // one of the default labels is not subscribed to
251 shouldSubscribeAll = true
252 break
253 }
254 }
255
256 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
257 LoggedInUser: user,
258 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
259 Branches: result.Branches,
260 Labels: labels,
261 DefaultLabels: defaultLabels,
262 SubscribedLabels: subscribedLabels,
263 ShouldSubscribeAll: shouldSubscribeAll,
264 Tabs: settingsTabs,
265 Tab: "general",
266 })
267}
268
269func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
270 l := rp.logger.With("handler", "accessSettings")
271
272 f, err := rp.repoResolver.Resolve(r)
273 user := rp.oauth.GetUser(r)
274
275 collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) {
276 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot)
277 if err != nil {
278 return nil, err
279 }
280 var collaborators []pages.Collaborator
281 for _, item := range repoCollaborators {
282 // currently only two roles: owner and member
283 var role string
284 switch item[3] {
285 case "repo:owner":
286 role = "owner"
287 case "repo:collaborator":
288 role = "collaborator"
289 default:
290 continue
291 }
292
293 did := item[0]
294
295 c := pages.Collaborator{
296 Did: did,
297 Role: role,
298 }
299 collaborators = append(collaborators, c)
300 }
301 return collaborators, nil
302 }(f)
303 if err != nil {
304 l.Error("failed to get collaborators", "err", err)
305 }
306
307 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
308 LoggedInUser: user,
309 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
310 Tabs: settingsTabs,
311 Tab: "access",
312 Collaborators: collaborators,
313 })
314}
315
316func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
317 l := rp.logger.With("handler", "pipelineSettings")
318
319 f, err := rp.repoResolver.Resolve(r)
320 user := rp.oauth.GetUser(r)
321
322 // all spindles that the repo owner is a member of
323 spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
324 if err != nil {
325 l.Error("failed to fetch spindles", "err", err)
326 return
327 }
328
329 var secrets []*tangled.RepoListSecrets_Secret
330 if f.Spindle != "" {
331 if spindleClient, err := rp.oauth.ServiceClient(
332 r,
333 oauth.WithService(f.Spindle),
334 oauth.WithLxm(tangled.RepoListSecretsNSID),
335 oauth.WithExp(60),
336 oauth.WithDev(rp.config.Core.Dev),
337 ); err != nil {
338 l.Error("failed to create spindle client", "err", err)
339 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
340 l.Error("failed to fetch secrets", "err", err)
341 } else {
342 secrets = resp.Secrets
343 }
344 }
345
346 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
347 return strings.Compare(a.Key, b.Key)
348 })
349
350 var dids []string
351 for _, s := range secrets {
352 dids = append(dids, s.CreatedBy)
353 }
354 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
355
356 // convert to a more manageable form
357 var niceSecret []map[string]any
358 for id, s := range secrets {
359 when, _ := time.Parse(time.RFC3339, s.CreatedAt)
360 niceSecret = append(niceSecret, map[string]any{
361 "Id": id,
362 "Key": s.Key,
363 "CreatedAt": when,
364 "CreatedBy": resolvedIdents[id].Handle.String(),
365 })
366 }
367
368 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
369 LoggedInUser: user,
370 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
371 Tabs: settingsTabs,
372 Tab: "pipelines",
373 Spindles: spindles,
374 CurrentSpindle: f.Spindle,
375 Secrets: niceSecret,
376 })
377}
378
379func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
380 l := rp.logger.With("handler", "EditBaseSettings")
381
382 noticeId := "repo-base-settings-error"
383
384 f, err := rp.repoResolver.Resolve(r)
385 if err != nil {
386 l.Error("failed to get repo and knot", "err", err)
387 w.WriteHeader(http.StatusBadRequest)
388 return
389 }
390
391 client, err := rp.oauth.AuthorizedClient(r)
392 if err != nil {
393 l.Error("failed to get client")
394 rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
395 return
396 }
397
398 var (
399 description = r.FormValue("description")
400 website = r.FormValue("website")
401 topicStr = r.FormValue("topics")
402 )
403
404 err = rp.validator.ValidateURI(website)
405 if website != "" && err != nil {
406 l.Error("invalid uri", "err", err)
407 rp.pages.Notice(w, noticeId, err.Error())
408 return
409 }
410
411 topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
412 if err != nil {
413 l.Error("invalid topics", "err", err)
414 rp.pages.Notice(w, noticeId, err.Error())
415 return
416 }
417 l.Debug("got", "topicsStr", topicStr, "topics", topics)
418
419 newRepo := *f
420 newRepo.Description = description
421 newRepo.Website = website
422 newRepo.Topics = topics
423 record := newRepo.AsRecord()
424
425 tx, err := rp.db.BeginTx(r.Context(), nil)
426 if err != nil {
427 l.Error("failed to begin transaction", "err", err)
428 rp.pages.Notice(w, noticeId, "Failed to save repository information.")
429 return
430 }
431 defer tx.Rollback()
432
433 err = db.PutRepo(tx, newRepo)
434 if err != nil {
435 l.Error("failed to update repository", "err", err)
436 rp.pages.Notice(w, noticeId, "Failed to save repository information.")
437 return
438 }
439
440 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
441 if err != nil {
442 // failed to get record
443 l.Error("failed to get repo record", "err", err)
444 rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
445 return
446 }
447 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
448 Collection: tangled.RepoNSID,
449 Repo: newRepo.Did,
450 Rkey: newRepo.Rkey,
451 SwapRecord: ex.Cid,
452 Record: &lexutil.LexiconTypeDecoder{
453 Val: &record,
454 },
455 })
456
457 if err != nil {
458 l.Error("failed to perferom update-repo query", "err", err)
459 // failed to get record
460 rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
461 return
462 }
463
464 err = tx.Commit()
465 if err != nil {
466 l.Error("failed to commit", "err", err)
467 }
468
469 rp.pages.HxRefresh(w)
470}