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