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