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}