Compare changes

Choose any two refs to compare.

+1 -20
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master","ci"] 3 + branch: master 4 4 5 5 engine: nixery 6 6 ··· 13 13 - name: patch static dir 14 14 command: | 15 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 - 17 - - name: run go mod tidy 18 - command: go mod tidy 19 - 20 - - name: run gomod2nix 21 - command: | 22 - # this var is overridden by the env setup otherwise 23 - export HOME=/tmp/build-home 24 - mkdir -p $HOME 25 - rm -rf /homeless-shelter || true 26 - nix run .#gomod2nix 27 - 28 - - name: verify no changes 29 - command: | 30 - if ! git diff --quiet; then 31 - echo "Error: gomod2nix produced changes. Please commit the updated files." 32 - git diff 33 - exit 1 34 - fi 35 16 36 17 - name: run linter 37 18 environment:
+79 -20
api/tangled/cbor_gen.go
··· 7934 7934 } 7935 7935 7936 7936 cw := cbg.NewCborWriter(w) 7937 - fieldCount := 9 7937 + fieldCount := 10 7938 7938 7939 7939 if t.Body == nil { 7940 7940 fieldCount-- 7941 7941 } 7942 7942 7943 7943 if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.Patch == nil { 7944 7948 fieldCount-- 7945 7949 } 7946 7950 ··· 8008 8012 } 8009 8013 8010 8014 // t.Patch (string) (string) 8011 - if len("patch") > 1000000 { 8012 - return xerrors.Errorf("Value in field \"patch\" was too long") 8013 - } 8015 + if t.Patch != nil { 8014 8016 8015 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8016 - return err 8017 - } 8018 - if _, err := cw.WriteString(string("patch")); err != nil { 8019 - return err 8020 - } 8017 + if len("patch") > 1000000 { 8018 + return xerrors.Errorf("Value in field \"patch\" was too long") 8019 + } 8021 8020 8022 - if len(t.Patch) > 1000000 { 8023 - return xerrors.Errorf("Value in field t.Patch was too long") 8024 - } 8021 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8022 + return err 8023 + } 8024 + if _, err := cw.WriteString(string("patch")); err != nil { 8025 + return err 8026 + } 8027 + 8028 + if t.Patch == nil { 8029 + if _, err := cw.Write(cbg.CborNull); err != nil { 8030 + return err 8031 + } 8032 + } else { 8033 + if len(*t.Patch) > 1000000 { 8034 + return xerrors.Errorf("Value in field t.Patch was too long") 8035 + } 8025 8036 8026 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil { 8027 - return err 8028 - } 8029 - if _, err := cw.WriteString(string(t.Patch)); err != nil { 8030 - return err 8037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil { 8038 + return err 8039 + } 8040 + if _, err := cw.WriteString(string(*t.Patch)); err != nil { 8041 + return err 8042 + } 8043 + } 8031 8044 } 8032 8045 8033 8046 // t.Title (string) (string) ··· 8147 8160 return err 8148 8161 } 8149 8162 8163 + // t.PatchBlob (util.LexBlob) (struct) 8164 + if len("patchBlob") > 1000000 { 8165 + return xerrors.Errorf("Value in field \"patchBlob\" was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil { 8169 + return err 8170 + } 8171 + if _, err := cw.WriteString(string("patchBlob")); err != nil { 8172 + return err 8173 + } 8174 + 8175 + if err := t.PatchBlob.MarshalCBOR(cw); err != nil { 8176 + return err 8177 + } 8178 + 8150 8179 // t.References ([]string) (slice) 8151 8180 if t.References != nil { 8152 8181 ··· 8262 8291 case "patch": 8263 8292 8264 8293 { 8265 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 8294 + b, err := cr.ReadByte() 8266 8295 if err != nil { 8267 8296 return err 8268 8297 } 8298 + if b != cbg.CborNull[0] { 8299 + if err := cr.UnreadByte(); err != nil { 8300 + return err 8301 + } 8269 8302 8270 - t.Patch = string(sval) 8303 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8304 + if err != nil { 8305 + return err 8306 + } 8307 + 8308 + t.Patch = (*string)(&sval) 8309 + } 8271 8310 } 8272 8311 // t.Title (string) (string) 8273 8312 case "title": ··· 8370 8409 } 8371 8410 8372 8411 t.CreatedAt = string(sval) 8412 + } 8413 + // t.PatchBlob (util.LexBlob) (struct) 8414 + case "patchBlob": 8415 + 8416 + { 8417 + 8418 + b, err := cr.ReadByte() 8419 + if err != nil { 8420 + return err 8421 + } 8422 + if b != cbg.CborNull[0] { 8423 + if err := cr.UnreadByte(); err != nil { 8424 + return err 8425 + } 8426 + t.PatchBlob = new(util.LexBlob) 8427 + if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil { 8428 + return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err) 8429 + } 8430 + } 8431 + 8373 8432 } 8374 8433 // t.References ([]string) (slice) 8375 8434 case "references":
+12 -9
api/tangled/repopull.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPull 19 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 - Patch string `json:"patch" cborgen:"patch"` 25 - References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 27 - Target *RepoPull_Target `json:"target" cborgen:"target"` 28 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + // patch: (deprecated) use patchBlob instead 25 + Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"` 26 + // patchBlob: patch content 27 + PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` 28 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 29 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 30 + Target *RepoPull_Target `json:"target" cborgen:"target"` 31 + Title string `json:"title" cborgen:"title"` 29 32 } 30 33 31 34 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+18 -11
appview/db/profile.go
··· 20 20 timeline := models.ProfileTimeline{ 21 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 22 } 23 - currentMonth := time.Now().Month() 23 + now := time.Now() 24 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 25 26 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 30 30 31 31 // group pulls by month 32 32 for _, pull := range pulls { 33 - pullMonth := pull.Created.Month() 33 + monthsAgo := monthsBetween(pull.Created, now) 34 34 35 - if currentMonth-pullMonth >= TimeframeMonths { 35 + if monthsAgo >= TimeframeMonths { 36 36 // shouldn't happen; but times are weird 37 37 continue 38 38 } 39 39 40 - idx := currentMonth - pullMonth 40 + idx := monthsAgo 41 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 42 43 43 *items = append(*items, &pull) ··· 53 53 } 54 54 55 55 for _, issue := range issues { 56 - issueMonth := issue.Created.Month() 56 + monthsAgo := monthsBetween(issue.Created, now) 57 57 58 - if currentMonth-issueMonth >= TimeframeMonths { 58 + if monthsAgo >= TimeframeMonths { 59 59 // shouldn't happen; but times are weird 60 60 continue 61 61 } 62 62 63 - idx := currentMonth - issueMonth 63 + idx := monthsAgo 64 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 65 66 66 *items = append(*items, &issue) ··· 77 77 if repo.Source != "" { 78 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 79 if err != nil { 80 - return nil, err 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 81 82 } 82 83 } 83 84 84 - repoMonth := repo.Created.Month() 85 + monthsAgo := monthsBetween(repo.Created, now) 85 86 86 - if currentMonth-repoMonth >= TimeframeMonths { 87 + if monthsAgo >= TimeframeMonths { 87 88 // shouldn't happen; but times are weird 88 89 continue 89 90 } 90 91 91 - idx := currentMonth - repoMonth 92 + idx := monthsAgo 92 93 93 94 items := &timeline.ByMonth[idx].RepoEvents 94 95 *items = append(*items, models.RepoEvent{ ··· 98 99 } 99 100 100 101 return &timeline, nil 102 + } 103 + 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 101 108 } 102 109 103 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+1 -1
appview/db/punchcard.go
··· 78 78 punch.Count = int(count.Int64) 79 79 } 80 80 81 - punchcard.Punches[punch.Date.YearDay()] = punch 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 82 82 punchcard.Total += punch.Count 83 83 } 84 84
+1
appview/db/repos.go
··· 158 158 from repo_languages 159 159 where repo_at in (%s) 160 160 and is_default_ref = 1 161 + and language <> '' 161 162 ) 162 163 where rn = 1 163 164 `,
+32 -32
appview/issues/issues.go
··· 81 81 82 82 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 83 83 l := rp.logger.With("handler", "RepoSingleIssue") 84 - user := rp.oauth.GetUser(r) 84 + user := rp.oauth.GetMultiAccountUser(r) 85 85 f, err := rp.repoResolver.Resolve(r) 86 86 if err != nil { 87 87 l.Error("failed to get repo and knot", "err", err) ··· 102 102 103 103 userReactions := map[models.ReactionKind]bool{} 104 104 if user != nil { 105 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 105 + userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 106 106 } 107 107 108 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 143 143 144 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 145 145 l := rp.logger.With("handler", "EditIssue") 146 - user := rp.oauth.GetUser(r) 146 + user := rp.oauth.GetMultiAccountUser(r) 147 147 148 148 issue, ok := r.Context().Value("issue").(*models.Issue) 149 149 if !ok { ··· 182 182 return 183 183 } 184 184 185 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 185 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Active.Did, newIssue.Rkey) 186 186 if err != nil { 187 187 l.Error("failed to get record", "err", err) 188 188 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") ··· 191 191 192 192 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 193 193 Collection: tangled.RepoIssueNSID, 194 - Repo: user.Did, 194 + Repo: user.Active.Did, 195 195 Rkey: newIssue.Rkey, 196 196 SwapRecord: ex.Cid, 197 197 Record: &lexutil.LexiconTypeDecoder{ ··· 292 292 293 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 294 294 l := rp.logger.With("handler", "CloseIssue") 295 - user := rp.oauth.GetUser(r) 295 + user := rp.oauth.GetMultiAccountUser(r) 296 296 f, err := rp.repoResolver.Resolve(r) 297 297 if err != nil { 298 298 l.Error("failed to get repo and knot", "err", err) ··· 306 306 return 307 307 } 308 308 309 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 310 310 isRepoOwner := roles.IsOwner() 311 311 isCollaborator := roles.IsCollaborator() 312 - isIssueOwner := user.Did == issue.Did 312 + isIssueOwner := user.Active.Did == issue.Did 313 313 314 314 // TODO: make this more granular 315 315 if isIssueOwner || isRepoOwner || isCollaborator { ··· 326 326 issue.Open = false 327 327 328 328 // notify about the issue closure 329 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 329 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 330 330 331 331 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 332 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 340 340 341 341 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 342 342 l := rp.logger.With("handler", "ReopenIssue") 343 - user := rp.oauth.GetUser(r) 343 + user := rp.oauth.GetMultiAccountUser(r) 344 344 f, err := rp.repoResolver.Resolve(r) 345 345 if err != nil { 346 346 l.Error("failed to get repo and knot", "err", err) ··· 354 354 return 355 355 } 356 356 357 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 358 358 isRepoOwner := roles.IsOwner() 359 359 isCollaborator := roles.IsCollaborator() 360 - isIssueOwner := user.Did == issue.Did 360 + isIssueOwner := user.Active.Did == issue.Did 361 361 362 362 if isCollaborator || isRepoOwner || isIssueOwner { 363 363 err := db.ReopenIssues( ··· 373 373 issue.Open = true 374 374 375 375 // notify about the issue reopen 376 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 376 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 377 377 378 378 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 379 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 387 387 388 388 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 389 389 l := rp.logger.With("handler", "NewIssueComment") 390 - user := rp.oauth.GetUser(r) 390 + user := rp.oauth.GetMultiAccountUser(r) 391 391 f, err := rp.repoResolver.Resolve(r) 392 392 if err != nil { 393 393 l.Error("failed to get repo and knot", "err", err) ··· 416 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 417 418 418 comment := models.IssueComment{ 419 - Did: user.Did, 419 + Did: user.Active.Did, 420 420 Rkey: tid.TID(), 421 421 IssueAt: issue.AtUri().String(), 422 422 ReplyTo: replyTo, ··· 495 495 496 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 497 497 l := rp.logger.With("handler", "IssueComment") 498 - user := rp.oauth.GetUser(r) 498 + user := rp.oauth.GetMultiAccountUser(r) 499 499 500 500 issue, ok := r.Context().Value("issue").(*models.Issue) 501 501 if !ok { ··· 531 531 532 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 533 533 l := rp.logger.With("handler", "EditIssueComment") 534 - user := rp.oauth.GetUser(r) 534 + user := rp.oauth.GetMultiAccountUser(r) 535 535 536 536 issue, ok := r.Context().Value("issue").(*models.Issue) 537 537 if !ok { ··· 557 557 } 558 558 comment := comments[0] 559 559 560 - if comment.Did != user.Did { 561 - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 560 + if comment.Did != user.Active.Did { 561 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 562 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 563 return 564 564 } ··· 608 608 // rkey is optional, it was introduced later 609 609 if newComment.Rkey != "" { 610 610 // update the record on pds 611 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 611 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) 612 612 if err != nil { 613 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 614 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 617 617 618 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 619 Collection: tangled.RepoIssueCommentNSID, 620 - Repo: user.Did, 620 + Repo: user.Active.Did, 621 621 Rkey: newComment.Rkey, 622 622 SwapRecord: ex.Cid, 623 623 Record: &lexutil.LexiconTypeDecoder{ ··· 641 641 642 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 643 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 644 - user := rp.oauth.GetUser(r) 644 + user := rp.oauth.GetMultiAccountUser(r) 645 645 646 646 issue, ok := r.Context().Value("issue").(*models.Issue) 647 647 if !ok { ··· 677 677 678 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 679 679 l := rp.logger.With("handler", "ReplyIssueComment") 680 - user := rp.oauth.GetUser(r) 680 + user := rp.oauth.GetMultiAccountUser(r) 681 681 682 682 issue, ok := r.Context().Value("issue").(*models.Issue) 683 683 if !ok { ··· 713 713 714 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 715 715 l := rp.logger.With("handler", "DeleteIssueComment") 716 - user := rp.oauth.GetUser(r) 716 + user := rp.oauth.GetMultiAccountUser(r) 717 717 718 718 issue, ok := r.Context().Value("issue").(*models.Issue) 719 719 if !ok { ··· 739 739 } 740 740 comment := comments[0] 741 741 742 - if comment.Did != user.Did { 743 - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 742 + if comment.Did != user.Active.Did { 743 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 744 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 745 745 return 746 746 } ··· 769 769 } 770 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 771 771 Collection: tangled.RepoIssueCommentNSID, 772 - Repo: user.Did, 772 + Repo: user.Active.Did, 773 773 Rkey: comment.Rkey, 774 774 }) 775 775 if err != nil { ··· 807 807 808 808 page := pagination.FromContext(r.Context()) 809 809 810 - user := rp.oauth.GetUser(r) 810 + user := rp.oauth.GetMultiAccountUser(r) 811 811 f, err := rp.repoResolver.Resolve(r) 812 812 if err != nil { 813 813 l.Error("failed to get repo and knot", "err", err) ··· 884 884 } 885 885 886 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 887 - LoggedInUser: rp.oauth.GetUser(r), 887 + LoggedInUser: rp.oauth.GetMultiAccountUser(r), 888 888 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 889 889 Issues: issues, 890 890 IssueCount: totalIssues, ··· 897 897 898 898 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 899 899 l := rp.logger.With("handler", "NewIssue") 900 - user := rp.oauth.GetUser(r) 900 + user := rp.oauth.GetMultiAccountUser(r) 901 901 902 902 f, err := rp.repoResolver.Resolve(r) 903 903 if err != nil { ··· 921 921 Title: r.FormValue("title"), 922 922 Body: body, 923 923 Open: true, 924 - Did: user.Did, 924 + Did: user.Active.Did, 925 925 Created: time.Now(), 926 926 Mentions: mentions, 927 927 References: references, ··· 945 945 } 946 946 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 947 947 Collection: tangled.RepoIssueNSID, 948 - Repo: user.Did, 948 + Repo: user.Active.Did, 949 949 Rkey: issue.Rkey, 950 950 Record: &lexutil.LexiconTypeDecoder{ 951 951 Val: &record,
+31 -36
appview/knots/knots.go
··· 70 70 } 71 71 72 72 func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 73 - user := k.OAuth.GetUser(r) 73 + user := k.OAuth.GetMultiAccountUser(r) 74 74 registrations, err := db.GetRegistrations( 75 75 k.Db, 76 - orm.FilterEq("did", user.Did), 76 + orm.FilterEq("did", user.Active.Did), 77 77 ) 78 78 if err != nil { 79 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 92 92 func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 93 93 l := k.Logger.With("handler", "dashboard") 94 94 95 - user := k.OAuth.GetUser(r) 96 - l = l.With("user", user.Did) 95 + user := k.OAuth.GetMultiAccountUser(r) 96 + l = l.With("user", user.Active.Did) 97 97 98 98 domain := chi.URLParam(r, "domain") 99 99 if domain == "" { ··· 103 103 104 104 registrations, err := db.GetRegistrations( 105 105 k.Db, 106 - orm.FilterEq("did", user.Did), 106 + orm.FilterEq("did", user.Active.Did), 107 107 orm.FilterEq("domain", domain), 108 108 ) 109 109 if err != nil { ··· 154 154 } 155 155 156 156 func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 157 - user := k.OAuth.GetUser(r) 157 + user := k.OAuth.GetMultiAccountUser(r) 158 158 l := k.Logger.With("handler", "register") 159 159 160 160 noticeId := "register-error" ··· 175 175 return 176 176 } 177 177 l = l.With("domain", domain) 178 - l = l.With("user", user.Did) 178 + l = l.With("user", user.Active.Did) 179 179 180 180 tx, err := k.Db.Begin() 181 181 if err != nil { ··· 188 188 k.Enforcer.E.LoadPolicy() 189 189 }() 190 190 191 - err = db.AddKnot(tx, domain, user.Did) 191 + err = db.AddKnot(tx, domain, user.Active.Did) 192 192 if err != nil { 193 193 l.Error("failed to insert", "err", err) 194 194 fail() ··· 210 210 return 211 211 } 212 212 213 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 213 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Active.Did, domain) 214 214 var exCid *string 215 215 if ex != nil { 216 216 exCid = ex.Cid ··· 219 219 // re-announce by registering under same rkey 220 220 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 221 221 Collection: tangled.KnotNSID, 222 - Repo: user.Did, 222 + Repo: user.Active.Did, 223 223 Rkey: domain, 224 224 Record: &lexutil.LexiconTypeDecoder{ 225 225 Val: &tangled.Knot{ ··· 250 250 } 251 251 252 252 // begin verification 253 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 253 + err = serververify.RunVerification(r.Context(), domain, user.Active.Did, k.Config.Core.Dev) 254 254 if err != nil { 255 255 l.Error("verification failed", "err", err) 256 256 k.Pages.HxRefresh(w) 257 257 return 258 258 } 259 259 260 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 260 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Active.Did) 261 261 if err != nil { 262 262 l.Error("failed to mark verified", "err", err) 263 263 k.Pages.HxRefresh(w) ··· 275 275 } 276 276 277 277 func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 278 - user := k.OAuth.GetUser(r) 278 + user := k.OAuth.GetMultiAccountUser(r) 279 279 l := k.Logger.With("handler", "delete") 280 280 281 281 noticeId := "operation-error" ··· 294 294 // get record from db first 295 295 registrations, err := db.GetRegistrations( 296 296 k.Db, 297 - orm.FilterEq("did", user.Did), 297 + orm.FilterEq("did", user.Active.Did), 298 298 orm.FilterEq("domain", domain), 299 299 ) 300 300 if err != nil { ··· 322 322 323 323 err = db.DeleteKnot( 324 324 tx, 325 - orm.FilterEq("did", user.Did), 325 + orm.FilterEq("did", user.Active.Did), 326 326 orm.FilterEq("domain", domain), 327 327 ) 328 328 if err != nil { ··· 350 350 351 351 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 352 352 Collection: tangled.KnotNSID, 353 - Repo: user.Did, 353 + Repo: user.Active.Did, 354 354 Rkey: domain, 355 355 }) 356 356 if err != nil { ··· 382 382 } 383 383 384 384 func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 385 - user := k.OAuth.GetUser(r) 385 + user := k.OAuth.GetMultiAccountUser(r) 386 386 l := k.Logger.With("handler", "retry") 387 387 388 388 noticeId := "operation-error" ··· 398 398 return 399 399 } 400 400 l = l.With("domain", domain) 401 - l = l.With("user", user.Did) 401 + l = l.With("user", user.Active.Did) 402 402 403 403 // get record from db first 404 404 registrations, err := db.GetRegistrations( 405 405 k.Db, 406 - orm.FilterEq("did", user.Did), 406 + orm.FilterEq("did", user.Active.Did), 407 407 orm.FilterEq("domain", domain), 408 408 ) 409 409 if err != nil { ··· 419 419 registration := registrations[0] 420 420 421 421 // begin verification 422 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 422 + err = serververify.RunVerification(r.Context(), domain, user.Active.Did, k.Config.Core.Dev) 423 423 if err != nil { 424 424 l.Error("verification failed", "err", err) 425 425 ··· 437 437 return 438 438 } 439 439 440 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 440 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Active.Did) 441 441 if err != nil { 442 442 l.Error("failed to mark verified", "err", err) 443 443 k.Pages.Notice(w, noticeId, err.Error()) ··· 456 456 return 457 457 } 458 458 459 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 459 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Active.Did, domain) 460 460 var exCid *string 461 461 if ex != nil { 462 462 exCid = ex.Cid ··· 465 465 // ignore the error here 466 466 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 467 467 Collection: tangled.KnotNSID, 468 - Repo: user.Did, 468 + Repo: user.Active.Did, 469 469 Rkey: domain, 470 470 Record: &lexutil.LexiconTypeDecoder{ 471 471 Val: &tangled.Knot{ ··· 494 494 // Get updated registration to show 495 495 registrations, err = db.GetRegistrations( 496 496 k.Db, 497 - orm.FilterEq("did", user.Did), 497 + orm.FilterEq("did", user.Active.Did), 498 498 orm.FilterEq("domain", domain), 499 499 ) 500 500 if err != nil { ··· 516 516 } 517 517 518 518 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 519 - user := k.OAuth.GetUser(r) 519 + user := k.OAuth.GetMultiAccountUser(r) 520 520 l := k.Logger.With("handler", "addMember") 521 521 522 522 domain := chi.URLParam(r, "domain") ··· 526 526 return 527 527 } 528 528 l = l.With("domain", domain) 529 - l = l.With("user", user.Did) 529 + l = l.With("user", user.Active.Did) 530 530 531 531 registrations, err := db.GetRegistrations( 532 532 k.Db, 533 - orm.FilterEq("did", user.Did), 533 + orm.FilterEq("did", user.Active.Did), 534 534 orm.FilterEq("domain", domain), 535 535 orm.FilterIsNot("registered", "null"), 536 536 ) ··· 583 583 584 584 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 585 585 Collection: tangled.KnotMemberNSID, 586 - Repo: user.Did, 586 + Repo: user.Active.Did, 587 587 Rkey: rkey, 588 588 Record: &lexutil.LexiconTypeDecoder{ 589 589 Val: &tangled.KnotMember{ ··· 618 618 } 619 619 620 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 621 - user := k.OAuth.GetUser(r) 621 + user := k.OAuth.GetMultiAccountUser(r) 622 622 l := k.Logger.With("handler", "removeMember") 623 623 624 624 noticeId := "operation-error" ··· 634 634 return 635 635 } 636 636 l = l.With("domain", domain) 637 - l = l.With("user", user.Did) 637 + l = l.With("user", user.Active.Did) 638 638 639 639 registrations, err := db.GetRegistrations( 640 640 k.Db, 641 - orm.FilterEq("did", user.Did), 641 + orm.FilterEq("did", user.Active.Did), 642 642 orm.FilterEq("domain", domain), 643 643 orm.FilterIsNot("registered", "null"), 644 644 ) ··· 663 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 664 664 if err != nil { 665 665 l.Error("failed to resolve member identity to handle", "err", err) 666 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 - return 668 - } 669 - if memberId.Handle.IsInvalidHandle() { 670 - l.Error("failed to resolve member identity to handle") 671 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 672 667 return 673 668 }
+2 -2
appview/labels/labels.go
··· 68 68 // - this handler should calculate the diff in order to create the labelop record 69 69 // - we need the diff in order to maintain a "history" of operations performed by users 70 70 func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { 71 - user := l.oauth.GetUser(r) 71 + user := l.oauth.GetMultiAccountUser(r) 72 72 73 73 noticeId := "add-label-error" 74 74 ··· 82 82 return 83 83 } 84 84 85 - did := user.Did 85 + did := user.Active.Did 86 86 rkey := tid.TID() 87 87 performedAt := time.Now() 88 88 indexedAt := time.Now()
+10 -8
appview/middleware/middleware.go
··· 115 115 return func(next http.Handler) http.Handler { 116 116 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 117 // requires auth also 118 - actor := mw.oauth.GetUser(r) 118 + actor := mw.oauth.GetMultiAccountUser(r) 119 119 if actor == nil { 120 120 // we need a logged in user 121 121 log.Printf("not logged in, redirecting") ··· 128 128 return 129 129 } 130 130 131 - ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 131 + ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Active.Did, group, domain) 132 132 if err != nil || !ok { 133 - // we need a logged in user 134 - log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 133 + log.Printf("%s does not have perms of a %s in domain %s", actor.Active.Did, group, domain) 135 134 http.Error(w, "Forbiden", http.StatusUnauthorized) 136 135 return 137 136 } ··· 149 148 return func(next http.Handler) http.Handler { 150 149 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 150 // requires auth also 152 - actor := mw.oauth.GetUser(r) 151 + actor := mw.oauth.GetMultiAccountUser(r) 153 152 if actor == nil { 154 153 // we need a logged in user 155 154 log.Printf("not logged in, redirecting") ··· 162 161 return 163 162 } 164 163 165 - ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 164 + ok, err := mw.enforcer.E.Enforce(actor.Active.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 166 165 if err != nil || !ok { 167 - // we need a logged in user 168 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 166 + log.Printf("%s does not have perms of a %s in repo %s", actor.Active.Did, requiredPerm, f.DidSlashRepo()) 169 167 http.Error(w, "Forbiden", http.StatusUnauthorized) 170 168 return 171 169 } ··· 223 221 ) 224 222 if err != nil { 225 223 log.Println("failed to resolve repo", "err", err) 224 + w.WriteHeader(http.StatusNotFound) 226 225 mw.pages.ErrorKnot404(w) 227 226 return 228 227 } ··· 240 239 f, err := mw.repoResolver.Resolve(r) 241 240 if err != nil { 242 241 log.Println("failed to fully resolve repo", err) 242 + w.WriteHeader(http.StatusNotFound) 243 243 mw.pages.ErrorKnot404(w) 244 244 return 245 245 } ··· 288 288 f, err := mw.repoResolver.Resolve(r) 289 289 if err != nil { 290 290 log.Println("failed to fully resolve repo", err) 291 + w.WriteHeader(http.StatusNotFound) 291 292 mw.pages.ErrorKnot404(w) 292 293 return 293 294 } ··· 324 325 f, err := mw.repoResolver.Resolve(r) 325 326 if err != nil { 326 327 log.Println("failed to fully resolve repo", err) 328 + w.WriteHeader(http.StatusNotFound) 327 329 mw.pages.ErrorKnot404(w) 328 330 return 329 331 }
+1 -1
appview/models/pull.go
··· 83 83 Repo *Repo 84 84 } 85 85 86 + // NOTE: This method does not include patch blob in returned atproto record 86 87 func (p Pull) AsRecord() tangled.RepoPull { 87 88 var source *tangled.RepoPull_Source 88 89 if p.PullSource != nil { ··· 113 114 Repo: p.RepoAt.String(), 114 115 Branch: p.TargetBranch, 115 116 }, 116 - Patch: p.LatestPatch(), 117 117 Source: source, 118 118 } 119 119 return record
+6 -6
appview/notifications/notifications.go
··· 48 48 49 49 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 50 50 l := n.logger.With("handler", "notificationsPage") 51 - user := n.oauth.GetUser(r) 51 + user := n.oauth.GetMultiAccountUser(r) 52 52 53 53 page := pagination.FromContext(r.Context()) 54 54 55 55 total, err := db.CountNotifications( 56 56 n.db, 57 - orm.FilterEq("recipient_did", user.Did), 57 + orm.FilterEq("recipient_did", user.Active.Did), 58 58 ) 59 59 if err != nil { 60 60 l.Error("failed to get total notifications", "err", err) ··· 65 65 notifications, err := db.GetNotificationsWithEntities( 66 66 n.db, 67 67 page, 68 - orm.FilterEq("recipient_did", user.Did), 68 + orm.FilterEq("recipient_did", user.Active.Did), 69 69 ) 70 70 if err != nil { 71 71 l.Error("failed to get notifications", "err", err) ··· 73 73 return 74 74 } 75 75 76 - err = db.MarkAllNotificationsRead(n.db, user.Did) 76 + err = db.MarkAllNotificationsRead(n.db, user.Active.Did) 77 77 if err != nil { 78 78 l.Error("failed to mark notifications as read", "err", err) 79 79 } ··· 90 90 } 91 91 92 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 - user := n.oauth.GetUser(r) 93 + user := n.oauth.GetMultiAccountUser(r) 94 94 if user == nil { 95 95 return 96 96 } 97 97 98 98 count, err := db.CountNotifications( 99 99 n.db, 100 - orm.FilterEq("recipient_did", user.Did), 100 + orm.FilterEq("recipient_did", user.Active.Did), 101 101 orm.FilterEq("read", 0), 102 102 ) 103 103 if err != nil {
+191
appview/oauth/accounts.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "net/http" 7 + "time" 8 + ) 9 + 10 + const MaxAccounts = 20 11 + 12 + var ErrMaxAccountsReached = errors.New("maximum number of linked accounts reached") 13 + 14 + type AccountInfo struct { 15 + Did string `json:"did"` 16 + Handle string `json:"handle"` 17 + SessionId string `json:"session_id"` 18 + AddedAt int64 `json:"added_at"` 19 + } 20 + 21 + type AccountRegistry struct { 22 + Accounts []AccountInfo `json:"accounts"` 23 + } 24 + 25 + type MultiAccountUser struct { 26 + Active *User 27 + Accounts []AccountInfo 28 + } 29 + 30 + func (m *MultiAccountUser) Did() string { 31 + if m.Active == nil { 32 + return "" 33 + } 34 + return m.Active.Did 35 + } 36 + 37 + func (m *MultiAccountUser) Pds() string { 38 + if m.Active == nil { 39 + return "" 40 + } 41 + return m.Active.Pds 42 + } 43 + 44 + func (o *OAuth) GetAccounts(r *http.Request) *AccountRegistry { 45 + session, err := o.SessStore.Get(r, AccountsName) 46 + if err != nil || session.IsNew { 47 + return &AccountRegistry{Accounts: []AccountInfo{}} 48 + } 49 + 50 + data, ok := session.Values["accounts"].(string) 51 + if !ok { 52 + return &AccountRegistry{Accounts: []AccountInfo{}} 53 + } 54 + 55 + var registry AccountRegistry 56 + if err := json.Unmarshal([]byte(data), &registry); err != nil { 57 + return &AccountRegistry{Accounts: []AccountInfo{}} 58 + } 59 + 60 + return &registry 61 + } 62 + 63 + func (o *OAuth) SaveAccounts(w http.ResponseWriter, r *http.Request, registry *AccountRegistry) error { 64 + session, err := o.SessStore.Get(r, AccountsName) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + data, err := json.Marshal(registry) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + session.Values["accounts"] = string(data) 75 + session.Options.MaxAge = 60 * 60 * 24 * 365 76 + session.Options.HttpOnly = true 77 + session.Options.Secure = !o.Config.Core.Dev 78 + session.Options.SameSite = http.SameSiteLaxMode 79 + 80 + return session.Save(r, w) 81 + } 82 + 83 + func (r *AccountRegistry) AddAccount(did, handle, sessionId string) error { 84 + for i, acc := range r.Accounts { 85 + if acc.Did == did { 86 + r.Accounts[i].SessionId = sessionId 87 + r.Accounts[i].Handle = handle 88 + return nil 89 + } 90 + } 91 + 92 + if len(r.Accounts) >= MaxAccounts { 93 + return ErrMaxAccountsReached 94 + } 95 + 96 + r.Accounts = append(r.Accounts, AccountInfo{ 97 + Did: did, 98 + Handle: handle, 99 + SessionId: sessionId, 100 + AddedAt: time.Now().Unix(), 101 + }) 102 + return nil 103 + } 104 + 105 + func (r *AccountRegistry) RemoveAccount(did string) { 106 + filtered := make([]AccountInfo, 0, len(r.Accounts)) 107 + for _, acc := range r.Accounts { 108 + if acc.Did != did { 109 + filtered = append(filtered, acc) 110 + } 111 + } 112 + r.Accounts = filtered 113 + } 114 + 115 + func (r *AccountRegistry) FindAccount(did string) *AccountInfo { 116 + for i := range r.Accounts { 117 + if r.Accounts[i].Did == did { 118 + return &r.Accounts[i] 119 + } 120 + } 121 + return nil 122 + } 123 + 124 + func (r *AccountRegistry) OtherAccounts(activeDid string) []AccountInfo { 125 + result := make([]AccountInfo, 0, len(r.Accounts)) 126 + for _, acc := range r.Accounts { 127 + if acc.Did != activeDid { 128 + result = append(result, acc) 129 + } 130 + } 131 + return result 132 + } 133 + 134 + func (o *OAuth) GetMultiAccountUser(r *http.Request) *MultiAccountUser { 135 + user := o.GetUser(r) 136 + if user == nil { 137 + return nil 138 + } 139 + 140 + registry := o.GetAccounts(r) 141 + return &MultiAccountUser{ 142 + Active: user, 143 + Accounts: registry.Accounts, 144 + } 145 + } 146 + 147 + type AuthReturnInfo struct { 148 + ReturnURL string 149 + AddAccount bool 150 + } 151 + 152 + func (o *OAuth) SetAuthReturn(w http.ResponseWriter, r *http.Request, returnURL string, addAccount bool) error { 153 + session, err := o.SessStore.Get(r, AuthReturnName) 154 + if err != nil { 155 + return err 156 + } 157 + 158 + session.Values[AuthReturnURL] = returnURL 159 + session.Values[AuthAddAccount] = addAccount 160 + session.Options.MaxAge = 60 * 30 161 + session.Options.HttpOnly = true 162 + session.Options.Secure = !o.Config.Core.Dev 163 + session.Options.SameSite = http.SameSiteLaxMode 164 + 165 + return session.Save(r, w) 166 + } 167 + 168 + func (o *OAuth) GetAuthReturn(r *http.Request) *AuthReturnInfo { 169 + session, err := o.SessStore.Get(r, AuthReturnName) 170 + if err != nil || session.IsNew { 171 + return &AuthReturnInfo{} 172 + } 173 + 174 + returnURL, _ := session.Values[AuthReturnURL].(string) 175 + addAccount, _ := session.Values[AuthAddAccount].(bool) 176 + 177 + return &AuthReturnInfo{ 178 + ReturnURL: returnURL, 179 + AddAccount: addAccount, 180 + } 181 + } 182 + 183 + func (o *OAuth) ClearAuthReturn(w http.ResponseWriter, r *http.Request) error { 184 + session, err := o.SessStore.Get(r, AuthReturnName) 185 + if err != nil { 186 + return err 187 + } 188 + 189 + session.Options.MaxAge = -1 190 + return session.Save(r, w) 191 + }
+265
appview/oauth/accounts_test.go
··· 1 + package oauth 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestAccountRegistry_AddAccount(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + initial []AccountInfo 11 + addDid string 12 + addHandle string 13 + addSessionId string 14 + wantErr error 15 + wantLen int 16 + wantSessionId string 17 + }{ 18 + { 19 + name: "add first account", 20 + initial: []AccountInfo{}, 21 + addDid: "did:plc:abc123", 22 + addHandle: "alice.bsky.social", 23 + addSessionId: "session-1", 24 + wantErr: nil, 25 + wantLen: 1, 26 + wantSessionId: "session-1", 27 + }, 28 + { 29 + name: "add second account", 30 + initial: []AccountInfo{ 31 + {Did: "did:plc:abc123", Handle: "alice.bsky.social", SessionId: "session-1", AddedAt: 1000}, 32 + }, 33 + addDid: "did:plc:def456", 34 + addHandle: "bob.bsky.social", 35 + addSessionId: "session-2", 36 + wantErr: nil, 37 + wantLen: 2, 38 + wantSessionId: "session-2", 39 + }, 40 + { 41 + name: "update existing account session", 42 + initial: []AccountInfo{ 43 + {Did: "did:plc:abc123", Handle: "alice.bsky.social", SessionId: "old-session", AddedAt: 1000}, 44 + }, 45 + addDid: "did:plc:abc123", 46 + addHandle: "alice.bsky.social", 47 + addSessionId: "new-session", 48 + wantErr: nil, 49 + wantLen: 1, 50 + wantSessionId: "new-session", 51 + }, 52 + } 53 + 54 + for _, tt := range tests { 55 + t.Run(tt.name, func(t *testing.T) { 56 + registry := &AccountRegistry{Accounts: tt.initial} 57 + err := registry.AddAccount(tt.addDid, tt.addHandle, tt.addSessionId) 58 + 59 + if err != tt.wantErr { 60 + t.Errorf("AddAccount() error = %v, want %v", err, tt.wantErr) 61 + } 62 + 63 + if len(registry.Accounts) != tt.wantLen { 64 + t.Errorf("AddAccount() len = %d, want %d", len(registry.Accounts), tt.wantLen) 65 + } 66 + 67 + found := registry.FindAccount(tt.addDid) 68 + if found == nil { 69 + t.Errorf("AddAccount() account not found after add") 70 + return 71 + } 72 + 73 + if found.SessionId != tt.wantSessionId { 74 + t.Errorf("AddAccount() sessionId = %s, want %s", found.SessionId, tt.wantSessionId) 75 + } 76 + }) 77 + } 78 + } 79 + 80 + func TestAccountRegistry_AddAccount_MaxLimit(t *testing.T) { 81 + registry := &AccountRegistry{Accounts: make([]AccountInfo, 0, MaxAccounts)} 82 + 83 + for i := range MaxAccounts { 84 + err := registry.AddAccount("did:plc:user"+string(rune('a'+i)), "handle", "session") 85 + if err != nil { 86 + t.Fatalf("AddAccount() unexpected error on account %d: %v", i, err) 87 + } 88 + } 89 + 90 + if len(registry.Accounts) != MaxAccounts { 91 + t.Errorf("expected %d accounts, got %d", MaxAccounts, len(registry.Accounts)) 92 + } 93 + 94 + err := registry.AddAccount("did:plc:overflow", "overflow", "session-overflow") 95 + if err != ErrMaxAccountsReached { 96 + t.Errorf("AddAccount() error = %v, want %v", err, ErrMaxAccountsReached) 97 + } 98 + 99 + if len(registry.Accounts) != MaxAccounts { 100 + t.Errorf("account added despite max limit, got %d", len(registry.Accounts)) 101 + } 102 + } 103 + 104 + func TestAccountRegistry_RemoveAccount(t *testing.T) { 105 + tests := []struct { 106 + name string 107 + initial []AccountInfo 108 + removeDid string 109 + wantLen int 110 + wantDids []string 111 + }{ 112 + { 113 + name: "remove existing account", 114 + initial: []AccountInfo{ 115 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 116 + {Did: "did:plc:def456", Handle: "bob", SessionId: "s2"}, 117 + }, 118 + removeDid: "did:plc:abc123", 119 + wantLen: 1, 120 + wantDids: []string{"did:plc:def456"}, 121 + }, 122 + { 123 + name: "remove non-existing account", 124 + initial: []AccountInfo{ 125 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 126 + }, 127 + removeDid: "did:plc:notfound", 128 + wantLen: 1, 129 + wantDids: []string{"did:plc:abc123"}, 130 + }, 131 + { 132 + name: "remove last account", 133 + initial: []AccountInfo{ 134 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 135 + }, 136 + removeDid: "did:plc:abc123", 137 + wantLen: 0, 138 + wantDids: []string{}, 139 + }, 140 + { 141 + name: "remove from empty registry", 142 + initial: []AccountInfo{}, 143 + removeDid: "did:plc:abc123", 144 + wantLen: 0, 145 + wantDids: []string{}, 146 + }, 147 + } 148 + 149 + for _, tt := range tests { 150 + t.Run(tt.name, func(t *testing.T) { 151 + registry := &AccountRegistry{Accounts: tt.initial} 152 + registry.RemoveAccount(tt.removeDid) 153 + 154 + if len(registry.Accounts) != tt.wantLen { 155 + t.Errorf("RemoveAccount() len = %d, want %d", len(registry.Accounts), tt.wantLen) 156 + } 157 + 158 + for _, wantDid := range tt.wantDids { 159 + if registry.FindAccount(wantDid) == nil { 160 + t.Errorf("RemoveAccount() expected %s to remain", wantDid) 161 + } 162 + } 163 + 164 + if registry.FindAccount(tt.removeDid) != nil && tt.wantLen < len(tt.initial) { 165 + t.Errorf("RemoveAccount() %s should have been removed", tt.removeDid) 166 + } 167 + }) 168 + } 169 + } 170 + 171 + func TestAccountRegistry_FindAccount(t *testing.T) { 172 + registry := &AccountRegistry{ 173 + Accounts: []AccountInfo{ 174 + {Did: "did:plc:first", Handle: "first", SessionId: "s1", AddedAt: 1000}, 175 + {Did: "did:plc:second", Handle: "second", SessionId: "s2", AddedAt: 2000}, 176 + {Did: "did:plc:third", Handle: "third", SessionId: "s3", AddedAt: 3000}, 177 + }, 178 + } 179 + 180 + t.Run("find existing account", func(t *testing.T) { 181 + found := registry.FindAccount("did:plc:second") 182 + if found == nil { 183 + t.Fatal("FindAccount() returned nil for existing account") 184 + } 185 + if found.Handle != "second" { 186 + t.Errorf("FindAccount() handle = %s, want second", found.Handle) 187 + } 188 + if found.SessionId != "s2" { 189 + t.Errorf("FindAccount() sessionId = %s, want s2", found.SessionId) 190 + } 191 + }) 192 + 193 + t.Run("find non-existing account", func(t *testing.T) { 194 + found := registry.FindAccount("did:plc:notfound") 195 + if found != nil { 196 + t.Errorf("FindAccount() = %v, want nil", found) 197 + } 198 + }) 199 + 200 + t.Run("returned pointer is mutable", func(t *testing.T) { 201 + found := registry.FindAccount("did:plc:first") 202 + if found == nil { 203 + t.Fatal("FindAccount() returned nil") 204 + } 205 + found.SessionId = "modified" 206 + 207 + refetch := registry.FindAccount("did:plc:first") 208 + if refetch.SessionId != "modified" { 209 + t.Errorf("FindAccount() pointer not referencing original, got %s", refetch.SessionId) 210 + } 211 + }) 212 + } 213 + 214 + func TestAccountRegistry_OtherAccounts(t *testing.T) { 215 + registry := &AccountRegistry{ 216 + Accounts: []AccountInfo{ 217 + {Did: "did:plc:active", Handle: "active", SessionId: "s1"}, 218 + {Did: "did:plc:other1", Handle: "other1", SessionId: "s2"}, 219 + {Did: "did:plc:other2", Handle: "other2", SessionId: "s3"}, 220 + }, 221 + } 222 + 223 + others := registry.OtherAccounts("did:plc:active") 224 + 225 + if len(others) != 2 { 226 + t.Errorf("OtherAccounts() len = %d, want 2", len(others)) 227 + } 228 + 229 + for _, acc := range others { 230 + if acc.Did == "did:plc:active" { 231 + t.Errorf("OtherAccounts() should not include active account") 232 + } 233 + } 234 + 235 + hasDid := func(did string) bool { 236 + for _, acc := range others { 237 + if acc.Did == did { 238 + return true 239 + } 240 + } 241 + return false 242 + } 243 + 244 + if !hasDid("did:plc:other1") || !hasDid("did:plc:other2") { 245 + t.Errorf("OtherAccounts() missing expected accounts") 246 + } 247 + } 248 + 249 + func TestMultiAccountUser_Did(t *testing.T) { 250 + t.Run("with active user", func(t *testing.T) { 251 + user := &MultiAccountUser{ 252 + Active: &User{Did: "did:plc:test", Pds: "https://bsky.social"}, 253 + } 254 + if user.Did() != "did:plc:test" { 255 + t.Errorf("Did() = %s, want did:plc:test", user.Did()) 256 + } 257 + }) 258 + 259 + t.Run("with nil active", func(t *testing.T) { 260 + user := &MultiAccountUser{Active: nil} 261 + if user.Did() != "" { 262 + t.Errorf("Did() = %s, want empty string", user.Did()) 263 + } 264 + }) 265 + }
+5 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session-v2" 4 + SessionName = "appview-session-v2" 5 + AccountsName = "appview-accounts-v2" 6 + AuthReturnName = "appview-auth-return" 7 + AuthReturnURL = "return_url" 8 + AuthAddAccount = "add_account" 5 9 SessionHandle = "handle" 6 10 SessionDid = "did" 7 11 SessionId = "id"
+14 -2
appview/oauth/handler.go
··· 55 55 ctx := r.Context() 56 56 l := o.Logger.With("query", r.URL.Query()) 57 57 58 + authReturn := o.GetAuthReturn(r) 59 + _ = o.ClearAuthReturn(w, r) 60 + 58 61 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 59 62 if err != nil { 60 63 var callbackErr *oauth.AuthRequestCallbackError ··· 70 73 71 74 if err := o.SaveSession(w, r, sessData); err != nil { 72 75 l.Error("failed to save session", "data", sessData, "err", err) 73 - http.Redirect(w, r, "/login?error=session", http.StatusFound) 76 + errorCode := "session" 77 + if errors.Is(err, ErrMaxAccountsReached) { 78 + errorCode = "max_accounts" 79 + } 80 + http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 74 81 return 75 82 } 76 83 ··· 88 95 } 89 96 } 90 97 91 - http.Redirect(w, r, "/", http.StatusFound) 98 + redirectURL := "/" 99 + if authReturn.ReturnURL != "" { 100 + redirectURL = authReturn.ReturnURL 101 + } 102 + 103 + http.Redirect(w, r, redirectURL, http.StatusFound) 92 104 } 93 105 94 106 func (o *OAuth) addToDefaultSpindle(did string) {
+66 -4
appview/oauth/oauth.go
··· 98 98 } 99 99 100 100 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 101 - // first we save the did in the user session 102 101 userSession, err := o.SessStore.Get(r, SessionName) 103 102 if err != nil { 104 103 return err ··· 108 107 userSession.Values[SessionPds] = sessData.HostURL 109 108 userSession.Values[SessionId] = sessData.SessionID 110 109 userSession.Values[SessionAuthenticated] = true 111 - return userSession.Save(r, w) 110 + 111 + if err := userSession.Save(r, w); err != nil { 112 + return err 113 + } 114 + 115 + handle := "" 116 + resolved, err := o.IdResolver.ResolveIdent(r.Context(), sessData.AccountDID.String()) 117 + if err == nil && resolved.Handle.String() != "" { 118 + handle = resolved.Handle.String() 119 + } 120 + 121 + registry := o.GetAccounts(r) 122 + if err := registry.AddAccount(sessData.AccountDID.String(), handle, sessData.SessionID); err != nil { 123 + return err 124 + } 125 + return o.SaveAccounts(w, r, registry) 112 126 } 113 127 114 128 func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { ··· 163 177 return errors.Join(err1, err2) 164 178 } 165 179 180 + func (o *OAuth) SwitchAccount(w http.ResponseWriter, r *http.Request, targetDid string) error { 181 + registry := o.GetAccounts(r) 182 + account := registry.FindAccount(targetDid) 183 + if account == nil { 184 + return fmt.Errorf("account not found in registry: %s", targetDid) 185 + } 186 + 187 + did, err := syntax.ParseDID(targetDid) 188 + if err != nil { 189 + return fmt.Errorf("invalid DID: %w", err) 190 + } 191 + 192 + sess, err := o.ClientApp.ResumeSession(r.Context(), did, account.SessionId) 193 + if err != nil { 194 + registry.RemoveAccount(targetDid) 195 + _ = o.SaveAccounts(w, r, registry) 196 + return fmt.Errorf("session expired for account: %w", err) 197 + } 198 + 199 + userSession, err := o.SessStore.Get(r, SessionName) 200 + if err != nil { 201 + return err 202 + } 203 + 204 + userSession.Values[SessionDid] = sess.Data.AccountDID.String() 205 + userSession.Values[SessionPds] = sess.Data.HostURL 206 + userSession.Values[SessionId] = sess.Data.SessionID 207 + userSession.Values[SessionAuthenticated] = true 208 + 209 + return userSession.Save(r, w) 210 + } 211 + 212 + func (o *OAuth) RemoveAccount(w http.ResponseWriter, r *http.Request, targetDid string) error { 213 + registry := o.GetAccounts(r) 214 + account := registry.FindAccount(targetDid) 215 + if account == nil { 216 + return nil 217 + } 218 + 219 + did, err := syntax.ParseDID(targetDid) 220 + if err == nil { 221 + _ = o.ClientApp.Logout(r.Context(), did, account.SessionId) 222 + } 223 + 224 + registry.RemoveAccount(targetDid) 225 + return o.SaveAccounts(w, r, registry) 226 + } 227 + 166 228 type User struct { 167 229 Did string 168 230 Pds string ··· 181 243 } 182 244 183 245 func (o *OAuth) GetDid(r *http.Request) string { 184 - if u := o.GetUser(r); u != nil { 185 - return u.Did 246 + if u := o.GetMultiAccountUser(r); u != nil { 247 + return u.Did() 186 248 } 187 249 188 250 return ""
+10
appview/pages/funcmap.go
··· 28 28 emoji "github.com/yuin/goldmark-emoji" 29 29 "tangled.org/core/appview/filetree" 30 30 "tangled.org/core/appview/models" 31 + "tangled.org/core/appview/oauth" 31 32 "tangled.org/core/appview/pages/markup" 32 33 "tangled.org/core/crypto" 33 34 ) ··· 384 385 return "error" 385 386 } 386 387 return fp 388 + }, 389 + "otherAccounts": func(activeDid string, accounts []oauth.AccountInfo) []oauth.AccountInfo { 390 + result := make([]oauth.AccountInfo, 0, len(accounts)) 391 + for _, acc := range accounts { 392 + if acc.Did != activeDid { 393 + result = append(result, acc) 394 + } 395 + } 396 + return result 387 397 }, 388 398 } 389 399 }
+13 -3
appview/pages/markup/extension/atlink.go
··· 35 35 return KindAt 36 36 } 37 37 38 - var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 39 40 40 41 type atParser struct{} 41 42 ··· 55 56 if m == nil { 56 57 return nil 57 58 } 59 + 60 + // Check for all links in the markdown to see if the handle found is inside one 61 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 62 + for _, linkMatch := range linksIndexes { 63 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 64 + return nil 65 + } 66 + } 67 + 58 68 atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 69 block.Advance(m[1]) 60 70 node := &AtNode{} ··· 87 97 88 98 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 99 if entering { 90 - w.WriteString(`<a href="/@`) 100 + w.WriteString(`<a href="/`) 91 101 w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention font-bold">`) 102 + w.WriteString(`" class="mention">`) 93 103 } else { 94 104 w.WriteString("</a>") 95 105 }
+121
appview/pages/markup/markdown_test.go
··· 1 + package markup 2 + 3 + import ( 4 + "bytes" 5 + "testing" 6 + ) 7 + 8 + func TestAtExtension_Rendering(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + markdown string 12 + expected string 13 + }{ 14 + { 15 + name: "renders simple at mention", 16 + markdown: "Hello @user.tngl.sh!", 17 + expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`, 18 + }, 19 + { 20 + name: "renders multiple at mentions", 21 + markdown: "Hi @alice.tngl.sh and @bob.example.com", 22 + expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`, 23 + }, 24 + { 25 + name: "renders at mention in parentheses", 26 + markdown: "Check this out (@user.tngl.sh)", 27 + expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`, 28 + }, 29 + { 30 + name: "does not render email", 31 + markdown: "Contact me at test@example.com", 32 + expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`, 33 + }, 34 + { 35 + name: "renders at mention with hyphen", 36 + markdown: "Follow @user-name.tngl.sh", 37 + expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`, 38 + }, 39 + { 40 + name: "renders at mention with numbers", 41 + markdown: "@user123.test456.social", 42 + expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`, 43 + }, 44 + { 45 + name: "at mention at start of line", 46 + markdown: "@user.tngl.sh is cool", 47 + expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`, 48 + }, 49 + } 50 + 51 + for _, tt := range tests { 52 + t.Run(tt.name, func(t *testing.T) { 53 + md := NewMarkdown() 54 + 55 + var buf bytes.Buffer 56 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 57 + t.Fatalf("failed to convert markdown: %v", err) 58 + } 59 + 60 + result := buf.String() 61 + if result != tt.expected+"\n" { 62 + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestAtExtension_WithOtherMarkdown(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + markdown string 72 + contains string 73 + }{ 74 + { 75 + name: "at mention with bold", 76 + markdown: "**Hello @user.tngl.sh**", 77 + contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`, 78 + }, 79 + { 80 + name: "at mention with italic", 81 + markdown: "*Check @user.tngl.sh*", 82 + contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`, 83 + }, 84 + { 85 + name: "at mention in list", 86 + markdown: "- Item 1\n- @user.tngl.sh\n- Item 3", 87 + contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`, 88 + }, 89 + { 90 + name: "at mention in link", 91 + markdown: "[@regnault.dev](https://regnault.dev)", 92 + contains: `<a href="https://regnault.dev">@regnault.dev</a>`, 93 + }, 94 + { 95 + name: "at mention in link again", 96 + markdown: "[check out @regnault.dev](https://regnault.dev)", 97 + contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`, 98 + }, 99 + { 100 + name: "at mention in link again, multiline", 101 + markdown: "[\ncheck out @regnault.dev](https://regnault.dev)", 102 + contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + md := NewMarkdown() 109 + 110 + var buf bytes.Buffer 111 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 112 + t.Fatalf("failed to convert markdown: %v", err) 113 + } 114 + 115 + result := buf.String() 116 + if !bytes.Contains([]byte(result), []byte(tt.contains)) { 117 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 118 + } 119 + }) 120 + } 121 + }
+68 -66
appview/pages/pages.go
··· 215 215 } 216 216 217 217 type LoginParams struct { 218 - ReturnUrl string 219 - ErrorCode string 218 + ReturnUrl string 219 + ErrorCode string 220 + AddAccount bool 221 + LoggedInUser *oauth.MultiAccountUser 220 222 } 221 223 222 224 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 236 238 } 237 239 238 240 type TermsOfServiceParams struct { 239 - LoggedInUser *oauth.User 241 + LoggedInUser *oauth.MultiAccountUser 240 242 Content template.HTML 241 243 } 242 244 ··· 264 266 } 265 267 266 268 type PrivacyPolicyParams struct { 267 - LoggedInUser *oauth.User 269 + LoggedInUser *oauth.MultiAccountUser 268 270 Content template.HTML 269 271 } 270 272 ··· 292 294 } 293 295 294 296 type BrandParams struct { 295 - LoggedInUser *oauth.User 297 + LoggedInUser *oauth.MultiAccountUser 296 298 } 297 299 298 300 func (p *Pages) Brand(w io.Writer, params BrandParams) error { ··· 300 302 } 301 303 302 304 type TimelineParams struct { 303 - LoggedInUser *oauth.User 305 + LoggedInUser *oauth.MultiAccountUser 304 306 Timeline []models.TimelineEvent 305 307 Repos []models.Repo 306 308 GfiLabel *models.LabelDefinition ··· 311 313 } 312 314 313 315 type GoodFirstIssuesParams struct { 314 - LoggedInUser *oauth.User 316 + LoggedInUser *oauth.MultiAccountUser 315 317 Issues []models.Issue 316 318 RepoGroups []*models.RepoGroup 317 319 LabelDefs map[string]*models.LabelDefinition ··· 324 326 } 325 327 326 328 type UserProfileSettingsParams struct { 327 - LoggedInUser *oauth.User 329 + LoggedInUser *oauth.MultiAccountUser 328 330 Tabs []map[string]any 329 331 Tab string 330 332 } ··· 334 336 } 335 337 336 338 type NotificationsParams struct { 337 - LoggedInUser *oauth.User 339 + LoggedInUser *oauth.MultiAccountUser 338 340 Notifications []*models.NotificationWithEntity 339 341 UnreadCount int 340 342 Page pagination.Page ··· 362 364 } 363 365 364 366 type UserKeysSettingsParams struct { 365 - LoggedInUser *oauth.User 367 + LoggedInUser *oauth.MultiAccountUser 366 368 PubKeys []models.PublicKey 367 369 Tabs []map[string]any 368 370 Tab string ··· 373 375 } 374 376 375 377 type UserEmailsSettingsParams struct { 376 - LoggedInUser *oauth.User 378 + LoggedInUser *oauth.MultiAccountUser 377 379 Emails []models.Email 378 380 Tabs []map[string]any 379 381 Tab string ··· 384 386 } 385 387 386 388 type UserNotificationSettingsParams struct { 387 - LoggedInUser *oauth.User 389 + LoggedInUser *oauth.MultiAccountUser 388 390 Preferences *models.NotificationPreferences 389 391 Tabs []map[string]any 390 392 Tab string ··· 404 406 } 405 407 406 408 type KnotsParams struct { 407 - LoggedInUser *oauth.User 409 + LoggedInUser *oauth.MultiAccountUser 408 410 Registrations []models.Registration 409 411 Tabs []map[string]any 410 412 Tab string ··· 415 417 } 416 418 417 419 type KnotParams struct { 418 - LoggedInUser *oauth.User 420 + LoggedInUser *oauth.MultiAccountUser 419 421 Registration *models.Registration 420 422 Members []string 421 423 Repos map[string][]models.Repo ··· 437 439 } 438 440 439 441 type SpindlesParams struct { 440 - LoggedInUser *oauth.User 442 + LoggedInUser *oauth.MultiAccountUser 441 443 Spindles []models.Spindle 442 444 Tabs []map[string]any 443 445 Tab string ··· 458 460 } 459 461 460 462 type SpindleDashboardParams struct { 461 - LoggedInUser *oauth.User 463 + LoggedInUser *oauth.MultiAccountUser 462 464 Spindle models.Spindle 463 465 Members []string 464 466 Repos map[string][]models.Repo ··· 471 473 } 472 474 473 475 type NewRepoParams struct { 474 - LoggedInUser *oauth.User 476 + LoggedInUser *oauth.MultiAccountUser 475 477 Knots []string 476 478 } 477 479 ··· 480 482 } 481 483 482 484 type ForkRepoParams struct { 483 - LoggedInUser *oauth.User 485 + LoggedInUser *oauth.MultiAccountUser 484 486 Knots []string 485 487 RepoInfo repoinfo.RepoInfo 486 488 } ··· 518 520 } 519 521 520 522 type ProfileOverviewParams struct { 521 - LoggedInUser *oauth.User 523 + LoggedInUser *oauth.MultiAccountUser 522 524 Repos []models.Repo 523 525 CollaboratingRepos []models.Repo 524 526 ProfileTimeline *models.ProfileTimeline ··· 532 534 } 533 535 534 536 type ProfileReposParams struct { 535 - LoggedInUser *oauth.User 537 + LoggedInUser *oauth.MultiAccountUser 536 538 Repos []models.Repo 537 539 Card *ProfileCard 538 540 Active string ··· 544 546 } 545 547 546 548 type ProfileStarredParams struct { 547 - LoggedInUser *oauth.User 549 + LoggedInUser *oauth.MultiAccountUser 548 550 Repos []models.Repo 549 551 Card *ProfileCard 550 552 Active string ··· 556 558 } 557 559 558 560 type ProfileStringsParams struct { 559 - LoggedInUser *oauth.User 561 + LoggedInUser *oauth.MultiAccountUser 560 562 Strings []models.String 561 563 Card *ProfileCard 562 564 Active string ··· 569 571 570 572 type FollowCard struct { 571 573 UserDid string 572 - LoggedInUser *oauth.User 574 + LoggedInUser *oauth.MultiAccountUser 573 575 FollowStatus models.FollowStatus 574 576 FollowersCount int64 575 577 FollowingCount int64 ··· 577 579 } 578 580 579 581 type ProfileFollowersParams struct { 580 - LoggedInUser *oauth.User 582 + LoggedInUser *oauth.MultiAccountUser 581 583 Followers []FollowCard 582 584 Card *ProfileCard 583 585 Active string ··· 589 591 } 590 592 591 593 type ProfileFollowingParams struct { 592 - LoggedInUser *oauth.User 594 + LoggedInUser *oauth.MultiAccountUser 593 595 Following []FollowCard 594 596 Card *ProfileCard 595 597 Active string ··· 610 612 } 611 613 612 614 type EditBioParams struct { 613 - LoggedInUser *oauth.User 615 + LoggedInUser *oauth.MultiAccountUser 614 616 Profile *models.Profile 615 617 } 616 618 ··· 619 621 } 620 622 621 623 type EditPinsParams struct { 622 - LoggedInUser *oauth.User 624 + LoggedInUser *oauth.MultiAccountUser 623 625 Profile *models.Profile 624 626 AllRepos []PinnedRepo 625 627 } ··· 644 646 } 645 647 646 648 type RepoIndexParams struct { 647 - LoggedInUser *oauth.User 649 + LoggedInUser *oauth.MultiAccountUser 648 650 RepoInfo repoinfo.RepoInfo 649 651 Active string 650 652 TagMap map[string][]string ··· 693 695 } 694 696 695 697 type RepoLogParams struct { 696 - LoggedInUser *oauth.User 698 + LoggedInUser *oauth.MultiAccountUser 697 699 RepoInfo repoinfo.RepoInfo 698 700 TagMap map[string][]string 699 701 Active string ··· 710 712 } 711 713 712 714 type RepoCommitParams struct { 713 - LoggedInUser *oauth.User 715 + LoggedInUser *oauth.MultiAccountUser 714 716 RepoInfo repoinfo.RepoInfo 715 717 Active string 716 718 EmailToDid map[string]string ··· 729 731 } 730 732 731 733 type RepoTreeParams struct { 732 - LoggedInUser *oauth.User 734 + LoggedInUser *oauth.MultiAccountUser 733 735 RepoInfo repoinfo.RepoInfo 734 736 Active string 735 737 BreadCrumbs [][]string ··· 784 786 } 785 787 786 788 type RepoBranchesParams struct { 787 - LoggedInUser *oauth.User 789 + LoggedInUser *oauth.MultiAccountUser 788 790 RepoInfo repoinfo.RepoInfo 789 791 Active string 790 792 types.RepoBranchesResponse ··· 796 798 } 797 799 798 800 type RepoTagsParams struct { 799 - LoggedInUser *oauth.User 801 + LoggedInUser *oauth.MultiAccountUser 800 802 RepoInfo repoinfo.RepoInfo 801 803 Active string 802 804 types.RepoTagsResponse ··· 810 812 } 811 813 812 814 type RepoArtifactParams struct { 813 - LoggedInUser *oauth.User 815 + LoggedInUser *oauth.MultiAccountUser 814 816 RepoInfo repoinfo.RepoInfo 815 817 Artifact models.Artifact 816 818 } ··· 820 822 } 821 823 822 824 type RepoBlobParams struct { 823 - LoggedInUser *oauth.User 825 + LoggedInUser *oauth.MultiAccountUser 824 826 RepoInfo repoinfo.RepoInfo 825 827 Active string 826 828 BreadCrumbs [][]string ··· 844 846 } 845 847 846 848 type RepoSettingsParams struct { 847 - LoggedInUser *oauth.User 849 + LoggedInUser *oauth.MultiAccountUser 848 850 RepoInfo repoinfo.RepoInfo 849 851 Collaborators []Collaborator 850 852 Active string ··· 863 865 } 864 866 865 867 type RepoGeneralSettingsParams struct { 866 - LoggedInUser *oauth.User 868 + LoggedInUser *oauth.MultiAccountUser 867 869 RepoInfo repoinfo.RepoInfo 868 870 Labels []models.LabelDefinition 869 871 DefaultLabels []models.LabelDefinition ··· 881 883 } 882 884 883 885 type RepoAccessSettingsParams struct { 884 - LoggedInUser *oauth.User 886 + LoggedInUser *oauth.MultiAccountUser 885 887 RepoInfo repoinfo.RepoInfo 886 888 Active string 887 889 Tabs []map[string]any ··· 895 897 } 896 898 897 899 type RepoPipelineSettingsParams struct { 898 - LoggedInUser *oauth.User 900 + LoggedInUser *oauth.MultiAccountUser 899 901 RepoInfo repoinfo.RepoInfo 900 902 Active string 901 903 Tabs []map[string]any ··· 911 913 } 912 914 913 915 type RepoIssuesParams struct { 914 - LoggedInUser *oauth.User 916 + LoggedInUser *oauth.MultiAccountUser 915 917 RepoInfo repoinfo.RepoInfo 916 918 Active string 917 919 Issues []models.Issue ··· 928 930 } 929 931 930 932 type RepoSingleIssueParams struct { 931 - LoggedInUser *oauth.User 933 + LoggedInUser *oauth.MultiAccountUser 932 934 RepoInfo repoinfo.RepoInfo 933 935 Active string 934 936 Issue *models.Issue ··· 947 949 } 948 950 949 951 type EditIssueParams struct { 950 - LoggedInUser *oauth.User 952 + LoggedInUser *oauth.MultiAccountUser 951 953 RepoInfo repoinfo.RepoInfo 952 954 Issue *models.Issue 953 955 Action string ··· 971 973 } 972 974 973 975 type RepoNewIssueParams struct { 974 - LoggedInUser *oauth.User 976 + LoggedInUser *oauth.MultiAccountUser 975 977 RepoInfo repoinfo.RepoInfo 976 978 Issue *models.Issue // existing issue if any -- passed when editing 977 979 Active string ··· 985 987 } 986 988 987 989 type EditIssueCommentParams struct { 988 - LoggedInUser *oauth.User 990 + LoggedInUser *oauth.MultiAccountUser 989 991 RepoInfo repoinfo.RepoInfo 990 992 Issue *models.Issue 991 993 Comment *models.IssueComment ··· 996 998 } 997 999 998 1000 type ReplyIssueCommentPlaceholderParams struct { 999 - LoggedInUser *oauth.User 1001 + LoggedInUser *oauth.MultiAccountUser 1000 1002 RepoInfo repoinfo.RepoInfo 1001 1003 Issue *models.Issue 1002 1004 Comment *models.IssueComment ··· 1007 1009 } 1008 1010 1009 1011 type ReplyIssueCommentParams struct { 1010 - LoggedInUser *oauth.User 1012 + LoggedInUser *oauth.MultiAccountUser 1011 1013 RepoInfo repoinfo.RepoInfo 1012 1014 Issue *models.Issue 1013 1015 Comment *models.IssueComment ··· 1018 1020 } 1019 1021 1020 1022 type IssueCommentBodyParams struct { 1021 - LoggedInUser *oauth.User 1023 + LoggedInUser *oauth.MultiAccountUser 1022 1024 RepoInfo repoinfo.RepoInfo 1023 1025 Issue *models.Issue 1024 1026 Comment *models.IssueComment ··· 1029 1031 } 1030 1032 1031 1033 type RepoNewPullParams struct { 1032 - LoggedInUser *oauth.User 1034 + LoggedInUser *oauth.MultiAccountUser 1033 1035 RepoInfo repoinfo.RepoInfo 1034 1036 Branches []types.Branch 1035 1037 Strategy string ··· 1046 1048 } 1047 1049 1048 1050 type RepoPullsParams struct { 1049 - LoggedInUser *oauth.User 1051 + LoggedInUser *oauth.MultiAccountUser 1050 1052 RepoInfo repoinfo.RepoInfo 1051 1053 Pulls []*models.Pull 1052 1054 Active string ··· 1081 1083 } 1082 1084 1083 1085 type RepoSinglePullParams struct { 1084 - LoggedInUser *oauth.User 1086 + LoggedInUser *oauth.MultiAccountUser 1085 1087 RepoInfo repoinfo.RepoInfo 1086 1088 Active string 1087 1089 Pull *models.Pull ··· 1106 1108 } 1107 1109 1108 1110 type RepoPullPatchParams struct { 1109 - LoggedInUser *oauth.User 1111 + LoggedInUser *oauth.MultiAccountUser 1110 1112 RepoInfo repoinfo.RepoInfo 1111 1113 Pull *models.Pull 1112 1114 Stack models.Stack ··· 1123 1125 } 1124 1126 1125 1127 type RepoPullInterdiffParams struct { 1126 - LoggedInUser *oauth.User 1128 + LoggedInUser *oauth.MultiAccountUser 1127 1129 RepoInfo repoinfo.RepoInfo 1128 1130 Pull *models.Pull 1129 1131 Round int ··· 1176 1178 } 1177 1179 1178 1180 type PullResubmitParams struct { 1179 - LoggedInUser *oauth.User 1181 + LoggedInUser *oauth.MultiAccountUser 1180 1182 RepoInfo repoinfo.RepoInfo 1181 1183 Pull *models.Pull 1182 1184 SubmissionId int ··· 1187 1189 } 1188 1190 1189 1191 type PullActionsParams struct { 1190 - LoggedInUser *oauth.User 1192 + LoggedInUser *oauth.MultiAccountUser 1191 1193 RepoInfo repoinfo.RepoInfo 1192 1194 Pull *models.Pull 1193 1195 RoundNumber int ··· 1202 1204 } 1203 1205 1204 1206 type PullNewCommentParams struct { 1205 - LoggedInUser *oauth.User 1207 + LoggedInUser *oauth.MultiAccountUser 1206 1208 RepoInfo repoinfo.RepoInfo 1207 1209 Pull *models.Pull 1208 1210 RoundNumber int ··· 1213 1215 } 1214 1216 1215 1217 type RepoCompareParams struct { 1216 - LoggedInUser *oauth.User 1218 + LoggedInUser *oauth.MultiAccountUser 1217 1219 RepoInfo repoinfo.RepoInfo 1218 1220 Forks []models.Repo 1219 1221 Branches []types.Branch ··· 1232 1234 } 1233 1235 1234 1236 type RepoCompareNewParams struct { 1235 - LoggedInUser *oauth.User 1237 + LoggedInUser *oauth.MultiAccountUser 1236 1238 RepoInfo repoinfo.RepoInfo 1237 1239 Forks []models.Repo 1238 1240 Branches []types.Branch ··· 1249 1251 } 1250 1252 1251 1253 type RepoCompareAllowPullParams struct { 1252 - LoggedInUser *oauth.User 1254 + LoggedInUser *oauth.MultiAccountUser 1253 1255 RepoInfo repoinfo.RepoInfo 1254 1256 Base string 1255 1257 Head string ··· 1269 1271 } 1270 1272 1271 1273 type LabelPanelParams struct { 1272 - LoggedInUser *oauth.User 1274 + LoggedInUser *oauth.MultiAccountUser 1273 1275 RepoInfo repoinfo.RepoInfo 1274 1276 Defs map[string]*models.LabelDefinition 1275 1277 Subject string ··· 1281 1283 } 1282 1284 1283 1285 type EditLabelPanelParams struct { 1284 - LoggedInUser *oauth.User 1286 + LoggedInUser *oauth.MultiAccountUser 1285 1287 RepoInfo repoinfo.RepoInfo 1286 1288 Defs map[string]*models.LabelDefinition 1287 1289 Subject string ··· 1293 1295 } 1294 1296 1295 1297 type PipelinesParams struct { 1296 - LoggedInUser *oauth.User 1298 + LoggedInUser *oauth.MultiAccountUser 1297 1299 RepoInfo repoinfo.RepoInfo 1298 1300 Pipelines []models.Pipeline 1299 1301 Active string ··· 1336 1338 } 1337 1339 1338 1340 type WorkflowParams struct { 1339 - LoggedInUser *oauth.User 1341 + LoggedInUser *oauth.MultiAccountUser 1340 1342 RepoInfo repoinfo.RepoInfo 1341 1343 Pipeline models.Pipeline 1342 1344 Workflow string ··· 1350 1352 } 1351 1353 1352 1354 type PutStringParams struct { 1353 - LoggedInUser *oauth.User 1355 + LoggedInUser *oauth.MultiAccountUser 1354 1356 Action string 1355 1357 1356 1358 // this is supplied in the case of editing an existing string ··· 1362 1364 } 1363 1365 1364 1366 type StringsDashboardParams struct { 1365 - LoggedInUser *oauth.User 1367 + LoggedInUser *oauth.MultiAccountUser 1366 1368 Card ProfileCard 1367 1369 Strings []models.String 1368 1370 } ··· 1372 1374 } 1373 1375 1374 1376 type StringTimelineParams struct { 1375 - LoggedInUser *oauth.User 1377 + LoggedInUser *oauth.MultiAccountUser 1376 1378 Strings []models.String 1377 1379 } 1378 1380 ··· 1381 1383 } 1382 1384 1383 1385 type SingleStringParams struct { 1384 - LoggedInUser *oauth.User 1386 + LoggedInUser *oauth.MultiAccountUser 1385 1387 ShowRendered bool 1386 1388 RenderToggle bool 1387 1389 RenderedContents template.HTML
+1 -1
appview/pages/templates/banner.html
··· 30 30 <div class="mx-6"> 31 31 These services may not be fully accessible until upgraded. 32 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 33 + href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 34 Click to read the upgrade guide</a>. 35 35 </div> 36 36 </details>
+1 -1
appview/pages/templates/knots/index.html
··· 105 105 {{ define "docsButton" }} 106 106 <a 107 107 class="btn flex items-center gap-2" 108 - href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md"> 108 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide"> 109 109 {{ i "book" "size-4" }} 110 110 docs 111 111 </a>
+2 -2
appview/pages/templates/layouts/fragments/footer.html
··· 26 26 <div class="flex flex-col gap-1"> 27 27 <div class="{{ $headerStyle }}">resources</div> 28 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 29 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 32 </div> ··· 73 73 <div class="flex flex-col gap-1"> 74 74 <div class="{{ $headerStyle }}">resources</div> 75 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 76 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 79 </div>
+49 -11
appview/pages/templates/layouts/fragments/topbar.html
··· 49 49 {{ define "profileDropdown" }} 50 50 <details class="relative inline-block text-left nav-dropdown"> 51 51 <summary class="cursor-pointer list-none flex items-center gap-1"> 52 - {{ $user := .Did }} 52 + {{ $user := .Active.Did }} 53 53 <img 54 54 src="{{ tinyAvatar $user }}" 55 55 alt="" ··· 57 57 /> 58 58 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 59 59 </summary> 60 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 61 - <a href="/{{ $user }}">profile</a> 62 - <a href="/{{ $user }}?tab=repos">repositories</a> 63 - <a href="/{{ $user }}?tab=strings">strings</a> 64 - <a href="/settings">settings</a> 65 - <a href="#" 66 - hx-post="/logout" 67 - hx-swap="none" 68 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 69 - logout 60 + <div class="absolute right-0 mt-4 p-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50" style="width: 14rem;"> 61 + {{ $active := .Active.Did }} 62 + 63 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 64 + <div class="flex items-center gap-2"> 65 + <img src="{{ tinyAvatar $active }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 66 + <div class="flex-1 overflow-hidden"> 67 + <p class="font-medium text-sm truncate">{{ $active | resolve }}</p> 68 + <p class="text-xs text-green-600 dark:text-green-400">active</p> 69 + </div> 70 + </div> 71 + </div> 72 + 73 + {{ $others := .Accounts | otherAccounts $active }} 74 + {{ if $others }} 75 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 76 + <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Switch Account</p> 77 + {{ range $others }} 78 + <button 79 + type="button" 80 + hx-post="/account/switch" 81 + hx-vals='{"did": "{{ .Did }}"}' 82 + hx-swap="none" 83 + class="flex items-center gap-2 w-full py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left" 84 + > 85 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-6 w-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 86 + <span class="text-sm truncate flex-1">{{ .Did | resolve }}</span> 87 + </button> 88 + {{ end }} 89 + </div> 90 + {{ end }} 91 + 92 + <a href="/login?mode=add_account" class="flex items-center gap-2 py-1 text-sm"> 93 + {{ i "plus" "w-4 h-4 flex-shrink-0" }} 94 + <span>Add another account</span> 70 95 </a> 96 + 97 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1"> 98 + <a href="/{{ $active }}" class="block py-1 text-sm">profile</a> 99 + <a href="/{{ $active }}?tab=repos" class="block py-1 text-sm">repositories</a> 100 + <a href="/{{ $active }}?tab=strings" class="block py-1 text-sm">strings</a> 101 + <a href="/settings" class="block py-1 text-sm">settings</a> 102 + <a href="#" 103 + hx-post="/logout" 104 + hx-swap="none" 105 + class="block py-1 text-sm text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 106 + logout 107 + </a> 108 + </div> 71 109 </div> 72 110 </details> 73 111
+1 -1
appview/pages/templates/repo/empty.html
··· 26 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 27 {{ $knot := .RepoInfo.Knot }} 28 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 30 {{ end }} 31 31 <div class="w-full flex place-content-center"> 32 32 <div class="py-6 w-fit flex flex-col gap-4">
+1 -1
appview/pages/templates/repo/fragments/diff.html
··· 17 17 {{ else }} 18 18 {{ range $idx, $hunk := $diff }} 19 19 {{ with $hunk }} 20 - <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 20 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 21 <summary class="list-none cursor-pointer sticky top-0"> 22 22 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 23 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+35 -35
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 13 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 14 14 {{- range .LeftLines -}} 15 15 {{- if .IsEmpty -}} 16 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 - </div> 16 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 18 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 19 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 20 + </span> 21 21 {{- else if eq .Op.String "-" -}} 22 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 - <div class="px-2">{{ .Content }}</div> 26 - </div> 22 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 24 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 25 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 26 + </span> 27 27 {{- else if eq .Op.String " " -}} 28 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 - <div class="px-2">{{ .Content }}</div> 32 - </div> 28 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 30 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 31 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 32 + </span> 33 33 {{- end -}} 34 34 {{- end -}} 35 - {{- end -}}</div></div></pre> 35 + {{- end -}}</div></div></div> 36 36 37 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 37 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 38 38 {{- range .RightLines -}} 39 39 {{- if .IsEmpty -}} 40 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 - </div> 40 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 42 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 43 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 44 + </span> 45 45 {{- else if eq .Op.String "+" -}} 46 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 - <div class="px-2" >{{ .Content }}</div> 50 - </div> 46 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span> 48 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 49 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 50 + </span> 51 51 {{- else if eq .Op.String " " -}} 52 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 - <div class="px-2">{{ .Content }}</div> 56 - </div> 52 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span> 54 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 55 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 56 + </span> 57 57 {{- end -}} 58 58 {{- end -}} 59 - {{- end -}}</div></div></pre> 59 + {{- end -}}</div></div></div> 60 60 </div> 61 61 {{ end }}
+21 -22
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 1 {{ define "repo/fragments/unifiedDiff" }} 2 2 {{ $name := .Id }} 3 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 3 + <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 4 {{- $oldStart := .OldPosition -}} 5 5 {{- $newStart := .NewPosition -}} 6 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 8 {{- $lineNrSepStyle1 := "" -}} 9 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 15 {{- range .Lines -}} 16 16 {{- if eq .Op.String "+" -}} 17 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 - <div class="px-2">{{ .Line }}</div> 22 - </div> 17 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span> 19 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span> 20 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 21 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 22 + </span> 23 23 {{- $newStart = add64 $newStart 1 -}} 24 24 {{- end -}} 25 25 {{- if eq .Op.String "-" -}} 26 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 - <div class="px-2">{{ .Line }}</div> 31 - </div> 26 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span> 28 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span> 29 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 30 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 31 + </span> 32 32 {{- $oldStart = add64 $oldStart 1 -}} 33 33 {{- end -}} 34 34 {{- if eq .Op.String " " -}} 35 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 - <div class="px-2">{{ .Line }}</div> 40 - </div> 35 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span> 37 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span> 38 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 39 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 40 + </span> 41 41 {{- $newStart = add64 $newStart 1 -}} 42 42 {{- $oldStart = add64 $oldStart 1 -}} 43 43 {{- end -}} 44 44 {{- end -}} 45 - {{- end -}}</div></div></pre> 45 + {{- end -}}</div></div></div> 46 46 {{ end }} 47 -
+1 -1
appview/pages/templates/repo/pipelines/pipelines.html
··· 23 23 </p> 24 24 <p> 25 25 <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 26 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 27 </p> 28 28 <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 29 </div>
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 23 Choose a spindle to execute your workflows on. Only repository owners 24 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 26 click to learn more. 27 27 </a> 28 28 </p>
+1 -1
appview/pages/templates/spindles/index.html
··· 102 102 {{ define "docsButton" }} 103 103 <a 104 104 class="btn flex items-center gap-2" 105 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 105 + href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 106 {{ i "book" "size-4" }} 107 107 docs 108 108 </a>
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 13 </a> 14 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 16 {{ end }} 17 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+53
appview/pages/templates/user/login.html
··· 20 20 <h2 class="text-center text-xl italic dark:text-white"> 21 21 tightly-knit social coding. 22 22 </h2> 23 + 24 + {{ if .AddAccount }} 25 + <div class="flex gap-2 my-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-sky-800 rounded px-3 py-2 text-blue-600 dark:text-blue-300"> 26 + <span class="py-1">{{ i "user-plus" "w-4 h-4" }}</span> 27 + <div> 28 + <h5 class="font-medium">Add another account</h5> 29 + <p class="text-sm">Sign in with a different account to add it to your account list.</p> 30 + </div> 31 + </div> 32 + {{ end }} 33 + 34 + {{ if and .LoggedInUser .LoggedInUser.Accounts }} 35 + {{ $accounts := .LoggedInUser.Accounts }} 36 + {{ if $accounts }} 37 + <div class="my-4 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"> 38 + <div class="px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> 39 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Saved accounts</span> 40 + </div> 41 + <div class="divide-y divide-gray-200 dark:divide-gray-700"> 42 + {{ range $accounts }} 43 + <div class="flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"> 44 + <button 45 + type="button" 46 + hx-post="/account/switch" 47 + hx-vals='{"did": "{{ .Did }}"}' 48 + hx-swap="none" 49 + class="flex items-center gap-2 flex-1 text-left min-w-0" 50 + > 51 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 52 + <div class="flex flex-col min-w-0"> 53 + <span class="text-sm font-medium dark:text-white truncate">{{ .Did | resolve | truncateAt30 }}</span> 54 + <span class="text-xs text-gray-500 dark:text-gray-400">Click to switch</span> 55 + </div> 56 + </button> 57 + <button 58 + type="button" 59 + hx-delete="/account/{{ .Did }}" 60 + hx-swap="none" 61 + class="p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 flex-shrink-0" 62 + title="Remove account" 63 + > 64 + {{ i "x" "w-4 h-4" }} 65 + </button> 66 + </div> 67 + {{ end }} 68 + </div> 69 + </div> 70 + {{ end }} 71 + {{ end }} 72 + 23 73 <form 24 74 class="mt-4" 25 75 hx-post="/login" ··· 46 96 </span> 47 97 </div> 48 98 <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 99 + <input type="hidden" name="add_account" value="{{ if .AddAccount }}true{{ end }}"> 49 100 50 101 <button 51 102 class="btn w-full my-2 mt-6 text-base " ··· 66 117 You have not authorized the app. 67 118 {{ else if eq .ErrorCode "session" }} 68 119 Server failed to create user session. 120 + {{ else if eq .ErrorCode "max_accounts" }} 121 + You have reached the maximum of 20 linked accounts. Please remove an account before adding a new one. 69 122 {{ else }} 70 123 Internal Server error. 71 124 {{ end }}
+2 -2
appview/pipelines/pipelines.go
··· 70 70 } 71 71 72 72 func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) { 73 - user := p.oauth.GetUser(r) 73 + user := p.oauth.GetMultiAccountUser(r) 74 74 l := p.logger.With("handler", "Index") 75 75 76 76 f, err := p.repoResolver.Resolve(r) ··· 99 99 } 100 100 101 101 func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) { 102 - user := p.oauth.GetUser(r) 102 + user := p.oauth.GetMultiAccountUser(r) 103 103 l := p.logger.With("handler", "Workflow") 104 104 105 105 f, err := p.repoResolver.Resolve(r)
+103 -91
appview/pulls/pulls.go
··· 93 93 func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 94 94 switch r.Method { 95 95 case http.MethodGet: 96 - user := s.oauth.GetUser(r) 96 + user := s.oauth.GetMultiAccountUser(r) 97 97 f, err := s.repoResolver.Resolve(r) 98 98 if err != nil { 99 99 log.Println("failed to get repo and knot", err) ··· 124 124 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 125 125 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 126 126 resubmitResult := pages.Unknown 127 - if user.Did == pull.OwnerDid { 127 + if user.Active.Did == pull.OwnerDid { 128 128 resubmitResult = s.resubmitCheck(r, f, pull, stack) 129 129 } 130 130 ··· 143 143 } 144 144 145 145 func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 146 - user := s.oauth.GetUser(r) 146 + user := s.oauth.GetMultiAccountUser(r) 147 147 f, err := s.repoResolver.Resolve(r) 148 148 if err != nil { 149 149 log.Println("failed to get repo and knot", err) ··· 171 171 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 172 172 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 173 173 resubmitResult := pages.Unknown 174 - if user != nil && user.Did == pull.OwnerDid { 174 + if user != nil && user.Active != nil && user.Active.Did == pull.OwnerDid { 175 175 resubmitResult = s.resubmitCheck(r, f, pull, stack) 176 176 } 177 177 ··· 213 213 214 214 userReactions := map[models.ReactionKind]bool{} 215 215 if user != nil { 216 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 216 + userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 217 217 } 218 218 219 219 labelDefs, err := db.GetLabelDefinitions( ··· 324 324 return nil 325 325 } 326 326 327 - user := s.oauth.GetUser(r) 327 + user := s.oauth.GetMultiAccountUser(r) 328 328 if user == nil { 329 329 return nil 330 330 } ··· 347 347 } 348 348 349 349 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 350 - perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 350 + perms := s.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 351 351 if !slices.Contains(perms, "repo:push") { 352 352 return nil 353 353 } ··· 434 434 } 435 435 436 436 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 437 - user := s.oauth.GetUser(r) 437 + user := s.oauth.GetMultiAccountUser(r) 438 438 439 439 var diffOpts types.DiffOpts 440 440 if d := r.URL.Query().Get("diff"); d == "split" { ··· 475 475 } 476 476 477 477 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 478 - user := s.oauth.GetUser(r) 478 + user := s.oauth.GetMultiAccountUser(r) 479 479 480 480 var diffOpts types.DiffOpts 481 481 if d := r.URL.Query().Get("diff"); d == "split" { ··· 520 520 interdiff := patchutil.Interdiff(previousPatch, currentPatch) 521 521 522 522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 - LoggedInUser: s.oauth.GetUser(r), 523 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 524 524 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 525 525 Pull: pull, 526 526 Round: roundIdInt, ··· 552 552 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 553 553 l := s.logger.With("handler", "RepoPulls") 554 554 555 - user := s.oauth.GetUser(r) 555 + user := s.oauth.GetMultiAccountUser(r) 556 556 params := r.URL.Query() 557 557 558 558 state := models.PullOpen ··· 680 680 } 681 681 682 682 s.pages.RepoPulls(w, pages.RepoPullsParams{ 683 - LoggedInUser: s.oauth.GetUser(r), 683 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 684 684 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 685 685 Pulls: pulls, 686 686 LabelDefs: defs, ··· 692 692 } 693 693 694 694 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 695 - user := s.oauth.GetUser(r) 695 + user := s.oauth.GetMultiAccountUser(r) 696 696 f, err := s.repoResolver.Resolve(r) 697 697 if err != nil { 698 698 log.Println("failed to get repo and knot", err) ··· 751 751 } 752 752 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 753 753 Collection: tangled.RepoPullCommentNSID, 754 - Repo: user.Did, 754 + Repo: user.Active.Did, 755 755 Rkey: tid.TID(), 756 756 Record: &lexutil.LexiconTypeDecoder{ 757 757 Val: &tangled.RepoPullComment{ ··· 768 768 } 769 769 770 770 comment := &models.PullComment{ 771 - OwnerDid: user.Did, 771 + OwnerDid: user.Active.Did, 772 772 RepoAt: f.RepoAt().String(), 773 773 PullId: pull.PullId, 774 774 Body: body, ··· 802 802 } 803 803 804 804 func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 805 - user := s.oauth.GetUser(r) 805 + user := s.oauth.GetMultiAccountUser(r) 806 806 f, err := s.repoResolver.Resolve(r) 807 807 if err != nil { 808 808 log.Println("failed to get repo and knot", err) ··· 870 870 } 871 871 872 872 // Determine PR type based on input parameters 873 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 873 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 874 874 isPushAllowed := roles.IsPushAllowed() 875 875 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 876 876 isForkBased := fromFork != "" && sourceBranch != "" ··· 970 970 w http.ResponseWriter, 971 971 r *http.Request, 972 972 repo *models.Repo, 973 - user *oauth.User, 973 + user *oauth.MultiAccountUser, 974 974 title, 975 975 body, 976 976 targetBranch, ··· 1027 1027 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1028 1028 } 1029 1029 1030 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1030 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1031 1031 if err := s.validator.ValidatePatch(&patch); err != nil { 1032 1032 s.logger.Error("patch validation failed", "err", err) 1033 1033 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") ··· 1037 1037 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1038 1038 } 1039 1039 1040 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1040 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1041 1041 repoString := strings.SplitN(forkRepo, "/", 2) 1042 1042 forkOwnerDid := repoString[0] 1043 1043 repoName := repoString[1] ··· 1146 1146 w http.ResponseWriter, 1147 1147 r *http.Request, 1148 1148 repo *models.Repo, 1149 - user *oauth.User, 1149 + user *oauth.MultiAccountUser, 1150 1150 title, body, targetBranch string, 1151 1151 patch string, 1152 1152 combined string, ··· 1218 1218 Title: title, 1219 1219 Body: body, 1220 1220 TargetBranch: targetBranch, 1221 - OwnerDid: user.Did, 1221 + OwnerDid: user.Active.Did, 1222 1222 RepoAt: repo.RepoAt(), 1223 1223 Rkey: rkey, 1224 1224 Mentions: mentions, ··· 1241 1241 return 1242 1242 } 1243 1243 1244 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1245 + if err != nil { 1246 + log.Println("failed to upload patch", err) 1247 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1248 + return 1249 + } 1250 + 1244 1251 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1245 1252 Collection: tangled.RepoPullNSID, 1246 - Repo: user.Did, 1253 + Repo: user.Active.Did, 1247 1254 Rkey: rkey, 1248 1255 Record: &lexutil.LexiconTypeDecoder{ 1249 1256 Val: &tangled.RepoPull{ ··· 1252 1259 Repo: string(repo.RepoAt()), 1253 1260 Branch: targetBranch, 1254 1261 }, 1255 - Patch: patch, 1262 + PatchBlob: blob.Blob, 1256 1263 Source: recordPullSource, 1257 1264 CreatedAt: time.Now().Format(time.RFC3339), 1258 1265 }, ··· 1280 1287 w http.ResponseWriter, 1281 1288 r *http.Request, 1282 1289 repo *models.Repo, 1283 - user *oauth.User, 1290 + user *oauth.MultiAccountUser, 1284 1291 targetBranch string, 1285 1292 patch string, 1286 1293 sourceRev string, ··· 1328 1335 // apply all record creations at once 1329 1336 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1330 1337 for _, p := range stack { 1338 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch())) 1339 + if err != nil { 1340 + log.Println("failed to upload patch blob", err) 1341 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1342 + return 1343 + } 1344 + 1331 1345 record := p.AsRecord() 1332 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1346 + record.PatchBlob = blob.Blob 1347 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1333 1348 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1334 1349 Collection: tangled.RepoPullNSID, 1335 1350 Rkey: &p.Rkey, ··· 1337 1352 Val: &record, 1338 1353 }, 1339 1354 }, 1340 - } 1341 - writes = append(writes, &write) 1355 + }) 1342 1356 } 1343 1357 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1344 - Repo: user.Did, 1358 + Repo: user.Active.Did, 1345 1359 Writes: writes, 1346 1360 }) 1347 1361 if err != nil { ··· 1413 1427 } 1414 1428 1415 1429 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1416 - user := s.oauth.GetUser(r) 1430 + user := s.oauth.GetMultiAccountUser(r) 1417 1431 1418 1432 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1419 1433 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 1421 1435 } 1422 1436 1423 1437 func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1424 - user := s.oauth.GetUser(r) 1438 + user := s.oauth.GetMultiAccountUser(r) 1425 1439 f, err := s.repoResolver.Resolve(r) 1426 1440 if err != nil { 1427 1441 log.Println("failed to get repo and knot", err) ··· 1476 1490 } 1477 1491 1478 1492 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1479 - user := s.oauth.GetUser(r) 1493 + user := s.oauth.GetMultiAccountUser(r) 1480 1494 1481 - forks, err := db.GetForksByDid(s.db, user.Did) 1495 + forks, err := db.GetForksByDid(s.db, user.Active.Did) 1482 1496 if err != nil { 1483 1497 log.Println("failed to get forks", err) 1484 1498 return ··· 1492 1506 } 1493 1507 1494 1508 func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1495 - user := s.oauth.GetUser(r) 1509 + user := s.oauth.GetMultiAccountUser(r) 1496 1510 1497 1511 f, err := s.repoResolver.Resolve(r) 1498 1512 if err != nil { ··· 1585 1599 } 1586 1600 1587 1601 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1588 - user := s.oauth.GetUser(r) 1602 + user := s.oauth.GetMultiAccountUser(r) 1589 1603 1590 1604 pull, ok := r.Context().Value("pull").(*models.Pull) 1591 1605 if !ok { ··· 1616 1630 } 1617 1631 1618 1632 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1619 - user := s.oauth.GetUser(r) 1633 + user := s.oauth.GetMultiAccountUser(r) 1620 1634 1621 1635 pull, ok := r.Context().Value("pull").(*models.Pull) 1622 1636 if !ok { ··· 1631 1645 return 1632 1646 } 1633 1647 1634 - if user.Did != pull.OwnerDid { 1648 + if user.Active.Did != pull.OwnerDid { 1635 1649 log.Println("unauthorized user") 1636 1650 w.WriteHeader(http.StatusUnauthorized) 1637 1651 return ··· 1643 1657 } 1644 1658 1645 1659 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1646 - user := s.oauth.GetUser(r) 1660 + user := s.oauth.GetMultiAccountUser(r) 1647 1661 1648 1662 pull, ok := r.Context().Value("pull").(*models.Pull) 1649 1663 if !ok { ··· 1658 1672 return 1659 1673 } 1660 1674 1661 - if user.Did != pull.OwnerDid { 1675 + if user.Active.Did != pull.OwnerDid { 1662 1676 log.Println("unauthorized user") 1663 1677 w.WriteHeader(http.StatusUnauthorized) 1664 1678 return 1665 1679 } 1666 1680 1667 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1681 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 1668 1682 if !roles.IsPushAllowed() { 1669 1683 log.Println("unauthorized user") 1670 1684 w.WriteHeader(http.StatusUnauthorized) ··· 1708 1722 } 1709 1723 1710 1724 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1711 - user := s.oauth.GetUser(r) 1725 + user := s.oauth.GetMultiAccountUser(r) 1712 1726 1713 1727 pull, ok := r.Context().Value("pull").(*models.Pull) 1714 1728 if !ok { ··· 1723 1737 return 1724 1738 } 1725 1739 1726 - if user.Did != pull.OwnerDid { 1740 + if user.Active.Did != pull.OwnerDid { 1727 1741 log.Println("unauthorized user") 1728 1742 w.WriteHeader(http.StatusUnauthorized) 1729 1743 return ··· 1808 1822 w http.ResponseWriter, 1809 1823 r *http.Request, 1810 1824 repo *models.Repo, 1811 - user *oauth.User, 1825 + user *oauth.MultiAccountUser, 1812 1826 pull *models.Pull, 1813 1827 patch string, 1814 1828 combined string, ··· 1864 1878 return 1865 1879 } 1866 1880 1867 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1881 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Active.Did, pull.Rkey) 1868 1882 if err != nil { 1869 1883 // failed to get record 1870 1884 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1871 1885 return 1872 1886 } 1873 1887 1874 - var recordPullSource *tangled.RepoPull_Source 1875 - if pull.IsBranchBased() { 1876 - recordPullSource = &tangled.RepoPull_Source{ 1877 - Branch: pull.PullSource.Branch, 1878 - Sha: sourceRev, 1879 - } 1888 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1889 + if err != nil { 1890 + log.Println("failed to upload patch blob", err) 1891 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1892 + return 1880 1893 } 1881 - if pull.IsForkBased() { 1882 - repoAt := pull.PullSource.RepoAt.String() 1883 - recordPullSource = &tangled.RepoPull_Source{ 1884 - Branch: pull.PullSource.Branch, 1885 - Repo: &repoAt, 1886 - Sha: sourceRev, 1887 - } 1888 - } 1894 + record := pull.AsRecord() 1895 + record.PatchBlob = blob.Blob 1896 + record.CreatedAt = time.Now().Format(time.RFC3339) 1889 1897 1890 1898 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1891 1899 Collection: tangled.RepoPullNSID, 1892 - Repo: user.Did, 1900 + Repo: user.Active.Did, 1893 1901 Rkey: pull.Rkey, 1894 1902 SwapRecord: ex.Cid, 1895 1903 Record: &lexutil.LexiconTypeDecoder{ 1896 - Val: &tangled.RepoPull{ 1897 - Title: pull.Title, 1898 - Target: &tangled.RepoPull_Target{ 1899 - Repo: string(repo.RepoAt()), 1900 - Branch: pull.TargetBranch, 1901 - }, 1902 - Patch: patch, // new patch 1903 - Source: recordPullSource, 1904 - CreatedAt: time.Now().Format(time.RFC3339), 1905 - }, 1904 + Val: &record, 1906 1905 }, 1907 1906 }) 1908 1907 if err != nil { ··· 1925 1924 w http.ResponseWriter, 1926 1925 r *http.Request, 1927 1926 repo *models.Repo, 1928 - user *oauth.User, 1927 + user *oauth.MultiAccountUser, 1929 1928 pull *models.Pull, 1930 1929 patch string, 1931 1930 stackId string, ··· 1987 1986 return 1988 1987 } 1989 1988 defer tx.Rollback() 1989 + 1990 + client, err := s.oauth.AuthorizedClient(r) 1991 + if err != nil { 1992 + log.Println("failed to authorize client") 1993 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1994 + return 1995 + } 1990 1996 1991 1997 // pds updates to make 1992 1998 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem ··· 2021 2027 return 2022 2028 } 2023 2029 2030 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2031 + if err != nil { 2032 + log.Println("failed to upload patch blob", err) 2033 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2034 + return 2035 + } 2024 2036 record := p.AsRecord() 2037 + record.PatchBlob = blob.Blob 2025 2038 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2026 2039 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2027 2040 Collection: tangled.RepoPullNSID, ··· 2056 2069 return 2057 2070 } 2058 2071 2072 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2073 + if err != nil { 2074 + log.Println("failed to upload patch blob", err) 2075 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2076 + return 2077 + } 2059 2078 record := np.AsRecord() 2060 - 2079 + record.PatchBlob = blob.Blob 2061 2080 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2062 2081 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2063 2082 Collection: tangled.RepoPullNSID, ··· 2094 2113 return 2095 2114 } 2096 2115 2097 - client, err := s.oauth.AuthorizedClient(r) 2098 - if err != nil { 2099 - log.Println("failed to authorize client") 2100 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2101 - return 2102 - } 2103 - 2104 2116 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2105 - Repo: user.Did, 2117 + Repo: user.Active.Did, 2106 2118 Writes: writes, 2107 2119 }) 2108 2120 if err != nil { ··· 2116 2128 } 2117 2129 2118 2130 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2119 - user := s.oauth.GetUser(r) 2131 + user := s.oauth.GetMultiAccountUser(r) 2120 2132 f, err := s.repoResolver.Resolve(r) 2121 2133 if err != nil { 2122 2134 log.Println("failed to resolve repo:", err) ··· 2227 2239 2228 2240 // notify about the pull merge 2229 2241 for _, p := range pullsToMerge { 2230 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2242 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2231 2243 } 2232 2244 2233 2245 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2235 2247 } 2236 2248 2237 2249 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2238 - user := s.oauth.GetUser(r) 2250 + user := s.oauth.GetMultiAccountUser(r) 2239 2251 2240 2252 f, err := s.repoResolver.Resolve(r) 2241 2253 if err != nil { ··· 2251 2263 } 2252 2264 2253 2265 // auth filter: only owner or collaborators can close 2254 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2266 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2255 2267 isOwner := roles.IsOwner() 2256 2268 isCollaborator := roles.IsCollaborator() 2257 - isPullAuthor := user.Did == pull.OwnerDid 2269 + isPullAuthor := user.Active.Did == pull.OwnerDid 2258 2270 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2259 2271 if !isCloseAllowed { 2260 2272 log.Println("failed to close pull") ··· 2300 2312 } 2301 2313 2302 2314 for _, p := range pullsToClose { 2303 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2315 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2304 2316 } 2305 2317 2306 2318 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2308 2320 } 2309 2321 2310 2322 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2311 - user := s.oauth.GetUser(r) 2323 + user := s.oauth.GetMultiAccountUser(r) 2312 2324 2313 2325 f, err := s.repoResolver.Resolve(r) 2314 2326 if err != nil { ··· 2325 2337 } 2326 2338 2327 2339 // auth filter: only owner or collaborators can close 2328 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2340 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2329 2341 isOwner := roles.IsOwner() 2330 2342 isCollaborator := roles.IsCollaborator() 2331 - isPullAuthor := user.Did == pull.OwnerDid 2343 + isPullAuthor := user.Active.Did == pull.OwnerDid 2332 2344 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2333 2345 if !isCloseAllowed { 2334 2346 log.Println("failed to close pull") ··· 2374 2386 } 2375 2387 2376 2388 for _, p := range pullsToReopen { 2377 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2389 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2378 2390 } 2379 2391 2380 2392 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2381 2393 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2382 2394 } 2383 2395 2384 - func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2396 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.MultiAccountUser, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2385 2397 formatPatches, err := patchutil.ExtractPatches(patch) 2386 2398 if err != nil { 2387 2399 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2417 2429 Title: title, 2418 2430 Body: body, 2419 2431 TargetBranch: targetBranch, 2420 - OwnerDid: user.Did, 2432 + OwnerDid: user.Active.Did, 2421 2433 RepoAt: repo.RepoAt(), 2422 2434 Rkey: rkey, 2423 2435 Mentions: mentions,
+1
appview/repo/archive.go
··· 18 18 l := rp.logger.With("handler", "DownloadArchive") 19 19 ref := chi.URLParam(r, "ref") 20 20 ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 21 22 f, err := rp.repoResolver.Resolve(r) 22 23 if err != nil { 23 24 l.Error("failed to get repo and knot", "err", err)
+6 -6
appview/repo/artifact.go
··· 30 30 31 31 // TODO: proper statuses here on early exit 32 32 func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { 33 - user := rp.oauth.GetUser(r) 33 + user := rp.oauth.GetMultiAccountUser(r) 34 34 tagParam := chi.URLParam(r, "tag") 35 35 f, err := rp.repoResolver.Resolve(r) 36 36 if err != nil { ··· 75 75 76 76 putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 77 Collection: tangled.RepoArtifactNSID, 78 - Repo: user.Did, 78 + Repo: user.Active.Did, 79 79 Rkey: rkey, 80 80 Record: &lexutil.LexiconTypeDecoder{ 81 81 Val: &tangled.RepoArtifact{ ··· 104 104 defer tx.Rollback() 105 105 106 106 artifact := models.Artifact{ 107 - Did: user.Did, 107 + Did: user.Active.Did, 108 108 Rkey: rkey, 109 109 RepoAt: f.RepoAt(), 110 110 Tag: tag.Tag.Hash, ··· 220 220 221 221 // TODO: proper statuses here on early exit 222 222 func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 223 - user := rp.oauth.GetUser(r) 223 + user := rp.oauth.GetMultiAccountUser(r) 224 224 tagParam := chi.URLParam(r, "tag") 225 225 filename := chi.URLParam(r, "file") 226 226 f, err := rp.repoResolver.Resolve(r) ··· 251 251 252 252 artifact := artifacts[0] 253 253 254 - if user.Did != artifact.Did { 254 + if user.Active.Did != artifact.Did { 255 255 log.Println("user not authorized to delete artifact", err) 256 256 rp.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 257 257 return ··· 259 259 260 260 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 261 261 Collection: tangled.RepoArtifactNSID, 262 - Repo: user.Did, 262 + Repo: user.Active.Did, 263 263 Rkey: artifact.Rkey, 264 264 }) 265 265 if err != nil {
+1 -1
appview/repo/blob.go
··· 76 76 // Create the blob view 77 77 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 78 79 - user := rp.oauth.GetUser(r) 79 + user := rp.oauth.GetMultiAccountUser(r) 80 80 81 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 82 LoggedInUser: user,
+1 -1
appview/repo/branches.go
··· 43 43 return 44 44 } 45 45 sortBranches(result.Branches) 46 - user := rp.oauth.GetUser(r) 46 + user := rp.oauth.GetMultiAccountUser(r) 47 47 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 48 LoggedInUser: user, 49 49 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
+2 -2
appview/repo/compare.go
··· 20 20 func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 21 l := rp.logger.With("handler", "RepoCompareNew") 22 22 23 - user := rp.oauth.GetUser(r) 23 + user := rp.oauth.GetMultiAccountUser(r) 24 24 f, err := rp.repoResolver.Resolve(r) 25 25 if err != nil { 26 26 l.Error("failed to get repo and knot", "err", err) ··· 101 101 func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 102 102 l := rp.logger.With("handler", "RepoCompare") 103 103 104 - user := rp.oauth.GetUser(r) 104 + user := rp.oauth.GetMultiAccountUser(r) 105 105 f, err := rp.repoResolver.Resolve(r) 106 106 if err != nil { 107 107 l.Error("failed to get repo and knot", "err", err)
+1 -1
appview/repo/index.go
··· 51 51 Host: host, 52 52 } 53 53 54 - user := rp.oauth.GetUser(r) 54 + user := rp.oauth.GetMultiAccountUser(r) 55 55 56 56 // Build index response from multiple XRPC calls 57 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
+2 -2
appview/repo/log.go
··· 109 109 } 110 110 } 111 111 112 - user := rp.oauth.GetUser(r) 112 + user := rp.oauth.GetMultiAccountUser(r) 113 113 114 114 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 115 if err != nil { ··· 197 197 l.Error("failed to GetVerifiedCommits", "err", err) 198 198 } 199 199 200 - user := rp.oauth.GetUser(r) 200 + user := rp.oauth.GetMultiAccountUser(r) 201 201 pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 202 if err != nil { 203 203 l.Error("failed to getPipelineStatuses", "err", err)
+34 -34
appview/repo/repo.go
··· 81 81 82 82 // modify the spindle configured for this repo 83 83 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 84 - user := rp.oauth.GetUser(r) 84 + user := rp.oauth.GetMultiAccountUser(r) 85 85 l := rp.logger.With("handler", "EditSpindle") 86 - l = l.With("did", user.Did) 86 + l = l.With("did", user.Active.Did) 87 87 88 88 errorId := "operation-error" 89 89 fail := func(msg string, err error) { ··· 107 107 108 108 if !removingSpindle { 109 109 // ensure that this is a valid spindle for this user 110 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 110 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Active.Did) 111 111 if err != nil { 112 112 fail("Failed to find spindles. Try again later.", err) 113 113 return ··· 168 168 } 169 169 170 170 func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 171 - user := rp.oauth.GetUser(r) 171 + user := rp.oauth.GetMultiAccountUser(r) 172 172 l := rp.logger.With("handler", "AddLabel") 173 - l = l.With("did", user.Did) 173 + l = l.With("did", user.Active.Did) 174 174 175 175 f, err := rp.repoResolver.Resolve(r) 176 176 if err != nil { ··· 216 216 } 217 217 218 218 label := models.LabelDefinition{ 219 - Did: user.Did, 219 + Did: user.Active.Did, 220 220 Rkey: tid.TID(), 221 221 Name: name, 222 222 ValueType: valueType, ··· 327 327 } 328 328 329 329 func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 330 - user := rp.oauth.GetUser(r) 330 + user := rp.oauth.GetMultiAccountUser(r) 331 331 l := rp.logger.With("handler", "DeleteLabel") 332 - l = l.With("did", user.Did) 332 + l = l.With("did", user.Active.Did) 333 333 334 334 f, err := rp.repoResolver.Resolve(r) 335 335 if err != nil { ··· 435 435 } 436 436 437 437 func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 438 - user := rp.oauth.GetUser(r) 438 + user := rp.oauth.GetMultiAccountUser(r) 439 439 l := rp.logger.With("handler", "SubscribeLabel") 440 - l = l.With("did", user.Did) 440 + l = l.With("did", user.Active.Did) 441 441 442 442 f, err := rp.repoResolver.Resolve(r) 443 443 if err != nil { ··· 521 521 } 522 522 523 523 func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 524 - user := rp.oauth.GetUser(r) 524 + user := rp.oauth.GetMultiAccountUser(r) 525 525 l := rp.logger.With("handler", "UnsubscribeLabel") 526 - l = l.With("did", user.Did) 526 + l = l.With("did", user.Active.Did) 527 527 528 528 f, err := rp.repoResolver.Resolve(r) 529 529 if err != nil { ··· 633 633 } 634 634 state := states[subject] 635 635 636 - user := rp.oauth.GetUser(r) 636 + user := rp.oauth.GetMultiAccountUser(r) 637 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 638 638 LoggedInUser: user, 639 639 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 681 681 } 682 682 state := states[subject] 683 683 684 - user := rp.oauth.GetUser(r) 684 + user := rp.oauth.GetMultiAccountUser(r) 685 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 686 686 LoggedInUser: user, 687 687 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 692 692 } 693 693 694 694 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 695 - user := rp.oauth.GetUser(r) 695 + user := rp.oauth.GetMultiAccountUser(r) 696 696 l := rp.logger.With("handler", "AddCollaborator") 697 - l = l.With("did", user.Did) 697 + l = l.With("did", user.Active.Did) 698 698 699 699 f, err := rp.repoResolver.Resolve(r) 700 700 if err != nil { ··· 723 723 return 724 724 } 725 725 726 - if collaboratorIdent.DID.String() == user.Did { 726 + if collaboratorIdent.DID.String() == user.Active.Did { 727 727 fail("You seem to be adding yourself as a collaborator.", nil) 728 728 return 729 729 } ··· 738 738 } 739 739 740 740 // emit a record 741 - currentUser := rp.oauth.GetUser(r) 741 + currentUser := rp.oauth.GetMultiAccountUser(r) 742 742 rkey := tid.TID() 743 743 createdAt := time.Now() 744 744 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 745 745 Collection: tangled.RepoCollaboratorNSID, 746 - Repo: currentUser.Did, 746 + Repo: currentUser.Active.Did, 747 747 Rkey: rkey, 748 748 Record: &lexutil.LexiconTypeDecoder{ 749 749 Val: &tangled.RepoCollaborator{ ··· 792 792 } 793 793 794 794 err = db.AddCollaborator(tx, models.Collaborator{ 795 - Did: syntax.DID(currentUser.Did), 795 + Did: syntax.DID(currentUser.Active.Did), 796 796 Rkey: rkey, 797 797 SubjectDid: collaboratorIdent.DID, 798 798 RepoAt: f.RepoAt(), ··· 822 822 } 823 823 824 824 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 825 - user := rp.oauth.GetUser(r) 825 + user := rp.oauth.GetMultiAccountUser(r) 826 826 l := rp.logger.With("handler", "DeleteRepo") 827 827 828 828 noticeId := "operation-error" ··· 840 840 } 841 841 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 842 842 Collection: tangled.RepoNSID, 843 - Repo: user.Did, 843 + Repo: user.Active.Did, 844 844 Rkey: f.Rkey, 845 845 }) 846 846 if err != nil { ··· 940 940 ref := chi.URLParam(r, "ref") 941 941 ref, _ = url.PathUnescape(ref) 942 942 943 - user := rp.oauth.GetUser(r) 943 + user := rp.oauth.GetMultiAccountUser(r) 944 944 f, err := rp.repoResolver.Resolve(r) 945 945 if err != nil { 946 946 l.Error("failed to resolve source repo", "err", err) ··· 969 969 r.Context(), 970 970 client, 971 971 &tangled.RepoForkSync_Input{ 972 - Did: user.Did, 972 + Did: user.Active.Did, 973 973 Name: f.Name, 974 974 Source: f.Source, 975 975 Branch: ref, ··· 988 988 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 989 989 l := rp.logger.With("handler", "ForkRepo") 990 990 991 - user := rp.oauth.GetUser(r) 991 + user := rp.oauth.GetMultiAccountUser(r) 992 992 f, err := rp.repoResolver.Resolve(r) 993 993 if err != nil { 994 994 l.Error("failed to resolve source repo", "err", err) ··· 997 997 998 998 switch r.Method { 999 999 case http.MethodGet: 1000 - user := rp.oauth.GetUser(r) 1001 - knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1000 + user := rp.oauth.GetMultiAccountUser(r) 1001 + knots, err := rp.enforcer.GetKnotsForUser(user.Active.Did) 1002 1002 if err != nil { 1003 1003 rp.pages.Notice(w, "repo", "Invalid user account.") 1004 1004 return ··· 1020 1020 } 1021 1021 l = l.With("targetKnot", targetKnot) 1022 1022 1023 - ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1023 + ok, err := rp.enforcer.E.Enforce(user.Active.Did, targetKnot, targetKnot, "repo:create") 1024 1024 if err != nil || !ok { 1025 1025 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1026 1026 return ··· 1037 1037 // in the user's account. 1038 1038 existingRepo, err := db.GetRepo( 1039 1039 rp.db, 1040 - orm.FilterEq("did", user.Did), 1040 + orm.FilterEq("did", user.Active.Did), 1041 1041 orm.FilterEq("name", forkName), 1042 1042 ) 1043 1043 if err != nil { ··· 1066 1066 // create an atproto record for this fork 1067 1067 rkey := tid.TID() 1068 1068 repo := &models.Repo{ 1069 - Did: user.Did, 1069 + Did: user.Active.Did, 1070 1070 Name: forkName, 1071 1071 Knot: targetKnot, 1072 1072 Rkey: rkey, ··· 1086 1086 1087 1087 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1088 1088 Collection: tangled.RepoNSID, 1089 - Repo: user.Did, 1089 + Repo: user.Active.Did, 1090 1090 Rkey: rkey, 1091 1091 Record: &lexutil.LexiconTypeDecoder{ 1092 1092 Val: &record, ··· 1165 1165 } 1166 1166 1167 1167 // acls 1168 - p, _ := securejoin.SecureJoin(user.Did, forkName) 1169 - err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1168 + p, _ := securejoin.SecureJoin(user.Active.Did, forkName) 1169 + err = rp.enforcer.AddRepo(user.Active.Did, targetKnot, p) 1170 1170 if err != nil { 1171 1171 l.Error("failed to add ACLs", "err", err) 1172 1172 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1191 1191 aturi = "" 1192 1192 1193 1193 rp.notifier.NewRepo(r.Context(), repo) 1194 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 1194 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, forkName)) 1195 1195 } 1196 1196 } 1197 1197
+5 -5
appview/repo/settings.go
··· 79 79 } 80 80 81 81 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 82 - user := rp.oauth.GetUser(r) 82 + user := rp.oauth.GetMultiAccountUser(r) 83 83 l := rp.logger.With("handler", "Secrets") 84 - l = l.With("did", user.Did) 84 + l = l.With("did", user.Active.Did) 85 85 86 86 f, err := rp.repoResolver.Resolve(r) 87 87 if err != nil { ··· 185 185 l := rp.logger.With("handler", "generalSettings") 186 186 187 187 f, err := rp.repoResolver.Resolve(r) 188 - user := rp.oauth.GetUser(r) 188 + user := rp.oauth.GetMultiAccountUser(r) 189 189 190 190 scheme := "http" 191 191 if !rp.config.Core.Dev { ··· 271 271 l := rp.logger.With("handler", "accessSettings") 272 272 273 273 f, err := rp.repoResolver.Resolve(r) 274 - user := rp.oauth.GetUser(r) 274 + user := rp.oauth.GetMultiAccountUser(r) 275 275 276 276 collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 277 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) ··· 318 318 l := rp.logger.With("handler", "pipelineSettings") 319 319 320 320 f, err := rp.repoResolver.Resolve(r) 321 - user := rp.oauth.GetUser(r) 321 + user := rp.oauth.GetMultiAccountUser(r) 322 322 323 323 // all spindles that the repo owner is a member of 324 324 spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
+1 -1
appview/repo/tags.go
··· 69 69 danglingArtifacts = append(danglingArtifacts, a) 70 70 } 71 71 } 72 - user := rp.oauth.GetUser(r) 72 + user := rp.oauth.GetMultiAccountUser(r) 73 73 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 74 LoggedInUser: user, 75 75 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
+1 -1
appview/repo/tree.go
··· 88 88 http.Redirect(w, r, redirectTo, http.StatusFound) 89 89 return 90 90 } 91 - user := rp.oauth.GetUser(r) 91 + user := rp.oauth.GetMultiAccountUser(r) 92 92 var breadcrumbs [][]string 93 93 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 94 94 if treePath != "" {
+30 -5
appview/reporesolver/resolver.go
··· 55 55 // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 56 // 3. [x] remove `ResolvedRepo` 57 57 // 4. [ ] replace reporesolver to reposervice 58 - func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo { 58 + func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.MultiAccountUser) repoinfo.RepoInfo { 59 59 ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 60 repo, rok := r.Context().Value("repo").(*models.Repo) 61 61 if !ook || !rok { ··· 63 63 } 64 64 65 65 // get dir/ref 66 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 67 ref := chi.URLParam(r, "ref") 68 68 69 69 repoAt := repo.RepoAt() 70 70 isStarred := false 71 71 roles := repoinfo.RolesInRepo{} 72 - if user != nil { 73 - isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt) 74 - roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 72 + if user != nil && user.Active != nil { 73 + isStarred = db.GetStarStatus(rr.execer, user.Active.Did, repoAt) 74 + roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 75 75 } 76 76 77 77 stats := repo.RepoStats ··· 130 130 } 131 131 132 132 return repoInfo 133 + } 134 + 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 146 + } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 133 158 } 134 159 135 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
+6 -6
appview/settings/settings.go
··· 81 81 } 82 82 83 83 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 84 - user := s.OAuth.GetUser(r) 84 + user := s.OAuth.GetMultiAccountUser(r) 85 85 86 86 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 87 87 LoggedInUser: user, ··· 91 91 } 92 92 93 93 func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 94 - user := s.OAuth.GetUser(r) 94 + user := s.OAuth.GetMultiAccountUser(r) 95 95 did := s.OAuth.GetDid(r) 96 96 97 97 prefs, err := db.GetNotificationPreference(s.Db, did) ··· 137 137 } 138 138 139 139 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 140 - user := s.OAuth.GetUser(r) 141 - pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 140 + user := s.OAuth.GetMultiAccountUser(r) 141 + pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Active.Did) 142 142 if err != nil { 143 143 log.Println(err) 144 144 } ··· 152 152 } 153 153 154 154 func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 155 - user := s.OAuth.GetUser(r) 156 - emails, err := db.GetAllEmails(s.Db, user.Did) 155 + user := s.OAuth.GetMultiAccountUser(r) 156 + emails, err := db.GetAllEmails(s.Db, user.Active.Did) 157 157 if err != nil { 158 158 log.Println(err) 159 159 }
+41 -46
appview/spindles/spindles.go
··· 69 69 } 70 70 71 71 func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 72 - user := s.OAuth.GetUser(r) 72 + user := s.OAuth.GetMultiAccountUser(r) 73 73 all, err := db.GetSpindles( 74 74 s.Db, 75 - orm.FilterEq("owner", user.Did), 75 + orm.FilterEq("owner", user.Active.Did), 76 76 ) 77 77 if err != nil { 78 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 91 91 func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 92 92 l := s.Logger.With("handler", "dashboard") 93 93 94 - user := s.OAuth.GetUser(r) 95 - l = l.With("user", user.Did) 94 + user := s.OAuth.GetMultiAccountUser(r) 95 + l = l.With("user", user.Active.Did) 96 96 97 97 instance := chi.URLParam(r, "instance") 98 98 if instance == "" { ··· 103 103 spindles, err := db.GetSpindles( 104 104 s.Db, 105 105 orm.FilterEq("instance", instance), 106 - orm.FilterEq("owner", user.Did), 106 + orm.FilterEq("owner", user.Active.Did), 107 107 orm.FilterIsNot("verified", "null"), 108 108 ) 109 109 if err != nil || len(spindles) != 1 { ··· 155 155 // 156 156 // if the spindle is not up yet, the user is free to retry verification at a later point 157 157 func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 158 - user := s.OAuth.GetUser(r) 158 + user := s.OAuth.GetMultiAccountUser(r) 159 159 l := s.Logger.With("handler", "register") 160 160 161 161 noticeId := "register-error" ··· 176 176 return 177 177 } 178 178 l = l.With("instance", instance) 179 - l = l.With("user", user.Did) 179 + l = l.With("user", user.Active.Did) 180 180 181 181 tx, err := s.Db.Begin() 182 182 if err != nil { ··· 190 190 }() 191 191 192 192 err = db.AddSpindle(tx, models.Spindle{ 193 - Owner: syntax.DID(user.Did), 193 + Owner: syntax.DID(user.Active.Did), 194 194 Instance: instance, 195 195 }) 196 196 if err != nil { ··· 214 214 return 215 215 } 216 216 217 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 217 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Active.Did, instance) 218 218 var exCid *string 219 219 if ex != nil { 220 220 exCid = ex.Cid ··· 223 223 // re-announce by registering under same rkey 224 224 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 225 225 Collection: tangled.SpindleNSID, 226 - Repo: user.Did, 226 + Repo: user.Active.Did, 227 227 Rkey: instance, 228 228 Record: &lexutil.LexiconTypeDecoder{ 229 229 Val: &tangled.Spindle{ ··· 254 254 } 255 255 256 256 // begin verification 257 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 257 + err = serververify.RunVerification(r.Context(), instance, user.Active.Did, s.Config.Core.Dev) 258 258 if err != nil { 259 259 l.Error("verification failed", "err", err) 260 260 s.Pages.HxRefresh(w) 261 261 return 262 262 } 263 263 264 - _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 264 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Active.Did) 265 265 if err != nil { 266 266 l.Error("failed to mark verified", "err", err) 267 267 s.Pages.HxRefresh(w) ··· 273 273 } 274 274 275 275 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 276 - user := s.OAuth.GetUser(r) 276 + user := s.OAuth.GetMultiAccountUser(r) 277 277 l := s.Logger.With("handler", "delete") 278 278 279 279 noticeId := "operation-error" ··· 291 291 292 292 spindles, err := db.GetSpindles( 293 293 s.Db, 294 - orm.FilterEq("owner", user.Did), 294 + orm.FilterEq("owner", user.Active.Did), 295 295 orm.FilterEq("instance", instance), 296 296 ) 297 297 if err != nil || len(spindles) != 1 { ··· 300 300 return 301 301 } 302 302 303 - if string(spindles[0].Owner) != user.Did { 304 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 303 + if string(spindles[0].Owner) != user.Active.Did { 304 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 305 305 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 306 306 return 307 307 } ··· 320 320 // remove spindle members first 321 321 err = db.RemoveSpindleMember( 322 322 tx, 323 - orm.FilterEq("did", user.Did), 323 + orm.FilterEq("did", user.Active.Did), 324 324 orm.FilterEq("instance", instance), 325 325 ) 326 326 if err != nil { ··· 331 331 332 332 err = db.DeleteSpindle( 333 333 tx, 334 - orm.FilterEq("owner", user.Did), 334 + orm.FilterEq("owner", user.Active.Did), 335 335 orm.FilterEq("instance", instance), 336 336 ) 337 337 if err != nil { ··· 359 359 360 360 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 361 361 Collection: tangled.SpindleNSID, 362 - Repo: user.Did, 362 + Repo: user.Active.Did, 363 363 Rkey: instance, 364 364 }) 365 365 if err != nil { ··· 391 391 } 392 392 393 393 func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 394 - user := s.OAuth.GetUser(r) 394 + user := s.OAuth.GetMultiAccountUser(r) 395 395 l := s.Logger.With("handler", "retry") 396 396 397 397 noticeId := "operation-error" ··· 407 407 return 408 408 } 409 409 l = l.With("instance", instance) 410 - l = l.With("user", user.Did) 410 + l = l.With("user", user.Active.Did) 411 411 412 412 spindles, err := db.GetSpindles( 413 413 s.Db, 414 - orm.FilterEq("owner", user.Did), 414 + orm.FilterEq("owner", user.Active.Did), 415 415 orm.FilterEq("instance", instance), 416 416 ) 417 417 if err != nil || len(spindles) != 1 { ··· 420 420 return 421 421 } 422 422 423 - if string(spindles[0].Owner) != user.Did { 424 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 423 + if string(spindles[0].Owner) != user.Active.Did { 424 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 425 425 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 426 426 return 427 427 } 428 428 429 429 // begin verification 430 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 430 + err = serververify.RunVerification(r.Context(), instance, user.Active.Did, s.Config.Core.Dev) 431 431 if err != nil { 432 432 l.Error("verification failed", "err", err) 433 433 ··· 445 445 return 446 446 } 447 447 448 - rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 448 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Active.Did) 449 449 if err != nil { 450 450 l.Error("failed to mark verified", "err", err) 451 451 s.Pages.Notice(w, noticeId, err.Error()) ··· 473 473 } 474 474 475 475 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 476 - user := s.OAuth.GetUser(r) 476 + user := s.OAuth.GetMultiAccountUser(r) 477 477 l := s.Logger.With("handler", "addMember") 478 478 479 479 instance := chi.URLParam(r, "instance") ··· 483 483 return 484 484 } 485 485 l = l.With("instance", instance) 486 - l = l.With("user", user.Did) 486 + l = l.With("user", user.Active.Did) 487 487 488 488 spindles, err := db.GetSpindles( 489 489 s.Db, 490 - orm.FilterEq("owner", user.Did), 490 + orm.FilterEq("owner", user.Active.Did), 491 491 orm.FilterEq("instance", instance), 492 492 ) 493 493 if err != nil || len(spindles) != 1 { ··· 502 502 s.Pages.Notice(w, noticeId, defaultErr) 503 503 } 504 504 505 - if string(spindles[0].Owner) != user.Did { 506 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 505 + if string(spindles[0].Owner) != user.Active.Did { 506 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 507 507 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 508 508 return 509 509 } ··· 552 552 553 553 // add member to db 554 554 if err = db.AddSpindleMember(tx, models.SpindleMember{ 555 - Did: syntax.DID(user.Did), 555 + Did: syntax.DID(user.Active.Did), 556 556 Rkey: rkey, 557 557 Instance: instance, 558 558 Subject: memberId.DID, ··· 570 570 571 571 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 572 572 Collection: tangled.SpindleMemberNSID, 573 - Repo: user.Did, 573 + Repo: user.Active.Did, 574 574 Rkey: rkey, 575 575 Record: &lexutil.LexiconTypeDecoder{ 576 576 Val: &tangled.SpindleMember{ ··· 603 603 } 604 604 605 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 606 - user := s.OAuth.GetUser(r) 606 + user := s.OAuth.GetMultiAccountUser(r) 607 607 l := s.Logger.With("handler", "removeMember") 608 608 609 609 noticeId := "operation-error" ··· 619 619 return 620 620 } 621 621 l = l.With("instance", instance) 622 - l = l.With("user", user.Did) 622 + l = l.With("user", user.Active.Did) 623 623 624 624 spindles, err := db.GetSpindles( 625 625 s.Db, 626 - orm.FilterEq("owner", user.Did), 626 + orm.FilterEq("owner", user.Active.Did), 627 627 orm.FilterEq("instance", instance), 628 628 ) 629 629 if err != nil || len(spindles) != 1 { ··· 632 632 return 633 633 } 634 634 635 - if string(spindles[0].Owner) != user.Did { 636 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 635 + if string(spindles[0].Owner) != user.Active.Did { 636 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 637 637 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 638 638 return 639 639 } ··· 653 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 654 return 655 655 } 656 - if memberId.Handle.IsInvalidHandle() { 657 - l.Error("failed to resolve member identity to handle") 658 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 659 - return 660 - } 661 656 662 657 tx, err := s.Db.Begin() 663 658 if err != nil { ··· 673 668 // get the record from the DB first: 674 669 members, err := db.GetSpindleMembers( 675 670 s.Db, 676 - orm.FilterEq("did", user.Did), 671 + orm.FilterEq("did", user.Active.Did), 677 672 orm.FilterEq("instance", instance), 678 673 orm.FilterEq("subject", memberId.DID), 679 674 ) ··· 686 681 // remove from db 687 682 if err = db.RemoveSpindleMember( 688 683 tx, 689 - orm.FilterEq("did", user.Did), 684 + orm.FilterEq("did", user.Active.Did), 690 685 orm.FilterEq("instance", instance), 691 686 orm.FilterEq("subject", memberId.DID), 692 687 ); err != nil { ··· 712 707 // remove from pds 713 708 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 714 709 Collection: tangled.SpindleMemberNSID, 715 - Repo: user.Did, 710 + Repo: user.Active.Did, 716 711 Rkey: members[0].Rkey, 717 712 }) 718 713 if err != nil {
+83
appview/state/accounts.go
··· 1 + package state 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + ) 8 + 9 + func (s *State) SwitchAccount(w http.ResponseWriter, r *http.Request) { 10 + l := s.logger.With("handler", "SwitchAccount") 11 + 12 + if err := r.ParseForm(); err != nil { 13 + l.Error("failed to parse form", "err", err) 14 + http.Error(w, "invalid request", http.StatusBadRequest) 15 + return 16 + } 17 + 18 + did := r.FormValue("did") 19 + if did == "" { 20 + http.Error(w, "missing did", http.StatusBadRequest) 21 + return 22 + } 23 + 24 + if err := s.oauth.SwitchAccount(w, r, did); err != nil { 25 + l.Error("failed to switch account", "err", err) 26 + s.pages.HxRedirect(w, "/login?error=session") 27 + return 28 + } 29 + 30 + l.Info("switched account", "did", did) 31 + s.pages.HxRedirect(w, "/") 32 + } 33 + 34 + func (s *State) RemoveAccount(w http.ResponseWriter, r *http.Request) { 35 + l := s.logger.With("handler", "RemoveAccount") 36 + 37 + did := chi.URLParam(r, "did") 38 + if did == "" { 39 + http.Error(w, "missing did", http.StatusBadRequest) 40 + return 41 + } 42 + 43 + currentUser := s.oauth.GetMultiAccountUser(r) 44 + isCurrentAccount := currentUser != nil && currentUser.Active.Did == did 45 + 46 + var remainingAccounts []string 47 + if currentUser != nil { 48 + for _, acc := range currentUser.Accounts { 49 + if acc.Did != did { 50 + remainingAccounts = append(remainingAccounts, acc.Did) 51 + } 52 + } 53 + } 54 + 55 + if err := s.oauth.RemoveAccount(w, r, did); err != nil { 56 + l.Error("failed to remove account", "err", err) 57 + http.Error(w, "failed to remove account", http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + l.Info("removed account", "did", did) 62 + 63 + if isCurrentAccount { 64 + if len(remainingAccounts) > 0 { 65 + nextDid := remainingAccounts[0] 66 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 67 + l.Error("failed to switch to next account", "err", err) 68 + s.pages.HxRedirect(w, "/login") 69 + return 70 + } 71 + s.pages.HxRefresh(w) 72 + return 73 + } 74 + 75 + if err := s.oauth.DeleteSession(w, r); err != nil { 76 + l.Error("failed to delete session", "err", err) 77 + } 78 + s.pages.HxRedirect(w, "/login") 79 + return 80 + } 81 + 82 + s.pages.HxRefresh(w) 83 + }
+7 -7
appview/state/follow.go
··· 15 15 ) 16 16 17 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 18 - currentUser := s.oauth.GetUser(r) 18 + currentUser := s.oauth.GetMultiAccountUser(r) 19 19 20 20 subject := r.URL.Query().Get("subject") 21 21 if subject == "" { ··· 29 29 return 30 30 } 31 31 32 - if currentUser.Did == subjectIdent.DID.String() { 32 + if currentUser.Active.Did == subjectIdent.DID.String() { 33 33 log.Println("cant follow or unfollow yourself") 34 34 return 35 35 } ··· 46 46 rkey := tid.TID() 47 47 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 48 Collection: tangled.GraphFollowNSID, 49 - Repo: currentUser.Did, 49 + Repo: currentUser.Active.Did, 50 50 Rkey: rkey, 51 51 Record: &lexutil.LexiconTypeDecoder{ 52 52 Val: &tangled.GraphFollow{ ··· 62 62 log.Println("created atproto record: ", resp.Uri) 63 63 64 64 follow := &models.Follow{ 65 - UserDid: currentUser.Did, 65 + UserDid: currentUser.Active.Did, 66 66 SubjectDid: subjectIdent.DID.String(), 67 67 Rkey: rkey, 68 68 } ··· 83 83 return 84 84 case http.MethodDelete: 85 85 // find the record in the db 86 - follow, err := db.GetFollow(s.db, currentUser.Did, subjectIdent.DID.String()) 86 + follow, err := db.GetFollow(s.db, currentUser.Active.Did, subjectIdent.DID.String()) 87 87 if err != nil { 88 88 log.Println("failed to get follow relationship") 89 89 return ··· 91 91 92 92 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 93 93 Collection: tangled.GraphFollowNSID, 94 - Repo: currentUser.Did, 94 + Repo: currentUser.Active.Did, 95 95 Rkey: follow.Rkey, 96 96 }) 97 97 ··· 100 100 return 101 101 } 102 102 103 - err = db.DeleteFollowByRkey(s.db, currentUser.Did, follow.Rkey) 103 + err = db.DeleteFollowByRkey(s.db, currentUser.Active.Did, follow.Rkey) 104 104 if err != nil { 105 105 log.Println("failed to delete follow from DB") 106 106 // this is not an issue, the firehose event might have already done this
+1 -1
appview/state/gfi.go
··· 15 15 ) 16 16 17 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 18 - user := s.oauth.GetUser(r) 18 + user := s.oauth.GetMultiAccountUser(r) 19 19 20 20 page := pagination.FromContext(r.Context()) 21 21
+57 -7
appview/state/login.go
··· 5 5 "net/http" 6 6 "strings" 7 7 8 + "tangled.org/core/appview/oauth" 8 9 "tangled.org/core/appview/pages" 9 10 ) 10 11 ··· 15 16 case http.MethodGet: 16 17 returnURL := r.URL.Query().Get("return_url") 17 18 errorCode := r.URL.Query().Get("error") 19 + addAccount := r.URL.Query().Get("mode") == "add_account" 20 + 21 + user := s.oauth.GetMultiAccountUser(r) 22 + if user == nil { 23 + registry := s.oauth.GetAccounts(r) 24 + if len(registry.Accounts) > 0 { 25 + user = &oauth.MultiAccountUser{ 26 + Active: nil, 27 + Accounts: registry.Accounts, 28 + } 29 + } 30 + } 18 31 s.pages.Login(w, pages.LoginParams{ 19 - ReturnUrl: returnURL, 20 - ErrorCode: errorCode, 32 + ReturnUrl: returnURL, 33 + ErrorCode: errorCode, 34 + AddAccount: addAccount, 35 + LoggedInUser: user, 21 36 }) 22 37 case http.MethodPost: 23 38 handle := r.FormValue("handle") 39 + returnURL := r.FormValue("return_url") 40 + addAccount := r.FormValue("add_account") == "true" 24 41 25 42 // when users copy their handle from bsky.app, it tends to have these characters around it: 26 43 // ··· 44 61 return 45 62 } 46 63 64 + if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { 65 + l.Error("failed to set auth return", "err", err) 66 + } 67 + 47 68 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 69 if err != nil { 49 70 l.Error("failed to start auth", "err", err) ··· 58 79 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 80 l := s.logger.With("handler", "Logout") 60 81 61 - err := s.oauth.DeleteSession(w, r) 62 - if err != nil { 63 - l.Error("failed to logout", "err", err) 64 - } else { 65 - l.Info("logged out successfully") 82 + currentUser := s.oauth.GetMultiAccountUser(r) 83 + if currentUser == nil || currentUser.Active == nil { 84 + s.pages.HxRedirect(w, "/login") 85 + return 66 86 } 67 87 88 + currentDid := currentUser.Active.Did 89 + 90 + var remainingAccounts []string 91 + for _, acc := range currentUser.Accounts { 92 + if acc.Did != currentDid { 93 + remainingAccounts = append(remainingAccounts, acc.Did) 94 + } 95 + } 96 + 97 + if err := s.oauth.RemoveAccount(w, r, currentDid); err != nil { 98 + l.Error("failed to remove account from registry", "err", err) 99 + } 100 + 101 + if err := s.oauth.DeleteSession(w, r); err != nil { 102 + l.Error("failed to delete session", "err", err) 103 + } 104 + 105 + if len(remainingAccounts) > 0 { 106 + nextDid := remainingAccounts[0] 107 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 108 + l.Error("failed to switch to next account", "err", err) 109 + s.pages.HxRedirect(w, "/login") 110 + return 111 + } 112 + l.Info("switched to next account after logout", "did", nextDid) 113 + s.pages.HxRefresh(w) 114 + return 115 + } 116 + 117 + l.Info("logged out last account") 68 118 s.pages.HxRedirect(w, "/login") 69 119 }
+38 -36
appview/state/profile.go
··· 77 77 return nil, fmt.Errorf("failed to get follower stats: %w", err) 78 78 } 79 79 80 - loggedInUser := s.oauth.GetUser(r) 80 + loggedInUser := s.oauth.GetMultiAccountUser(r) 81 81 followStatus := models.IsNotFollowing 82 82 if loggedInUser != nil { 83 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 83 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Active.Did, did) 84 84 } 85 85 86 86 now := time.Now() ··· 163 163 } 164 164 165 165 // populate commit counts in the timeline, using the punchcard 166 - currentMonth := time.Now().Month() 166 + now := time.Now() 167 167 for _, p := range profile.Punchcard.Punches { 168 - idx := currentMonth - p.Date.Month() 169 - if int(idx) < len(timeline.ByMonth) { 170 - timeline.ByMonth[idx].Commits += p.Count 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 171 173 } 172 174 } 173 175 174 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 175 - LoggedInUser: s.oauth.GetUser(r), 177 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 176 178 Card: profile, 177 179 Repos: pinnedRepos, 178 180 CollaboratingRepos: pinnedCollaboratingRepos, ··· 203 205 } 204 206 205 207 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 206 - LoggedInUser: s.oauth.GetUser(r), 208 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 207 209 Repos: repos, 208 210 Card: profile, 209 211 }) ··· 232 234 } 233 235 234 236 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 235 - LoggedInUser: s.oauth.GetUser(r), 237 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 236 238 Repos: repos, 237 239 Card: profile, 238 240 }) ··· 257 259 } 258 260 259 261 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 260 - LoggedInUser: s.oauth.GetUser(r), 262 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 261 263 Strings: strings, 262 264 Card: profile, 263 265 }) ··· 281 283 } 282 284 l = l.With("profileDid", profile.UserDid) 283 285 284 - loggedInUser := s.oauth.GetUser(r) 286 + loggedInUser := s.oauth.GetMultiAccountUser(r) 285 287 params := FollowsPageParams{ 286 288 Card: profile, 287 289 } ··· 314 316 315 317 loggedInUserFollowing := make(map[string]struct{}) 316 318 if loggedInUser != nil { 317 - following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 + following, err := db.GetFollowing(s.db, loggedInUser.Active.Did) 318 320 if err != nil { 319 - l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Active.Did) 320 322 return &params, err 321 323 } 322 324 loggedInUserFollowing = make(map[string]struct{}, len(following)) ··· 331 333 followStatus := models.IsNotFollowing 332 334 if _, exists := loggedInUserFollowing[did]; exists { 333 335 followStatus = models.IsFollowing 334 - } else if loggedInUser != nil && loggedInUser.Did == did { 336 + } else if loggedInUser != nil && loggedInUser.Active.Did == did { 335 337 followStatus = models.IsSelf 336 338 } 337 339 ··· 365 367 } 366 368 367 369 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 - LoggedInUser: s.oauth.GetUser(r), 370 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 369 371 Followers: followPage.Follows, 370 372 Card: followPage.Card, 371 373 }) ··· 379 381 } 380 382 381 383 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 - LoggedInUser: s.oauth.GetUser(r), 384 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 383 385 Following: followPage.Follows, 384 386 Card: followPage.Card, 385 387 }) ··· 528 530 } 529 531 530 532 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 531 - user := s.oauth.GetUser(r) 533 + user := s.oauth.GetMultiAccountUser(r) 532 534 533 535 err := r.ParseForm() 534 536 if err != nil { ··· 537 539 return 538 540 } 539 541 540 - profile, err := db.GetProfile(s.db, user.Did) 542 + profile, err := db.GetProfile(s.db, user.Active.Did) 541 543 if err != nil { 542 - log.Printf("getting profile data for %s: %s", user.Did, err) 544 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 543 545 } 544 546 545 547 profile.Description = r.FormValue("description") ··· 576 578 } 577 579 578 580 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 579 - user := s.oauth.GetUser(r) 581 + user := s.oauth.GetMultiAccountUser(r) 580 582 581 583 err := r.ParseForm() 582 584 if err != nil { ··· 585 587 return 586 588 } 587 589 588 - profile, err := db.GetProfile(s.db, user.Did) 590 + profile, err := db.GetProfile(s.db, user.Active.Did) 589 591 if err != nil { 590 - log.Printf("getting profile data for %s: %s", user.Did, err) 592 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 591 593 } 592 594 593 595 i := 0 ··· 615 617 } 616 618 617 619 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 618 - user := s.oauth.GetUser(r) 620 + user := s.oauth.GetMultiAccountUser(r) 619 621 tx, err := s.db.BeginTx(r.Context(), nil) 620 622 if err != nil { 621 623 log.Println("failed to start transaction", err) ··· 642 644 vanityStats = append(vanityStats, string(v.Kind)) 643 645 } 644 646 645 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 647 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Active.Did, "self") 646 648 var cid *string 647 649 if ex != nil { 648 650 cid = ex.Cid ··· 650 652 651 653 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 652 654 Collection: tangled.ActorProfileNSID, 653 - Repo: user.Did, 655 + Repo: user.Active.Did, 654 656 Rkey: "self", 655 657 Record: &lexutil.LexiconTypeDecoder{ 656 658 Val: &tangled.ActorProfile{ ··· 679 681 680 682 s.notifier.UpdateProfile(r.Context(), profile) 681 683 682 - s.pages.HxRedirect(w, "/"+user.Did) 684 + s.pages.HxRedirect(w, "/"+user.Active.Did) 683 685 } 684 686 685 687 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 686 - user := s.oauth.GetUser(r) 688 + user := s.oauth.GetMultiAccountUser(r) 687 689 688 - profile, err := db.GetProfile(s.db, user.Did) 690 + profile, err := db.GetProfile(s.db, user.Active.Did) 689 691 if err != nil { 690 - log.Printf("getting profile data for %s: %s", user.Did, err) 692 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 691 693 } 692 694 693 695 s.pages.EditBioFragment(w, pages.EditBioParams{ ··· 697 699 } 698 700 699 701 func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 700 - user := s.oauth.GetUser(r) 702 + user := s.oauth.GetMultiAccountUser(r) 701 703 702 - profile, err := db.GetProfile(s.db, user.Did) 704 + profile, err := db.GetProfile(s.db, user.Active.Did) 703 705 if err != nil { 704 - log.Printf("getting profile data for %s: %s", user.Did, err) 706 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 705 707 } 706 708 707 - repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did)) 709 + repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Active.Did)) 708 710 if err != nil { 709 - log.Printf("getting repos for %s: %s", user.Did, err) 711 + log.Printf("getting repos for %s: %s", user.Active.Did, err) 710 712 } 711 713 712 - collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 714 + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Active.Did) 713 715 if err != nil { 714 - log.Printf("getting collaborating repos for %s: %s", user.Did, err) 716 + log.Printf("getting collaborating repos for %s: %s", user.Active.Did, err) 715 717 } 716 718 717 719 allRepos := []pages.PinnedRepo{}
+7 -7
appview/state/reaction.go
··· 17 17 ) 18 18 19 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { 20 - currentUser := s.oauth.GetUser(r) 20 + currentUser := s.oauth.GetMultiAccountUser(r) 21 21 22 22 subject := r.URL.Query().Get("subject") 23 23 if subject == "" { ··· 49 49 rkey := tid.TID() 50 50 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 - Repo: currentUser.Did, 52 + Repo: currentUser.Active.Did, 53 53 Rkey: rkey, 54 54 Record: &lexutil.LexiconTypeDecoder{ 55 55 Val: &tangled.FeedReaction{ ··· 64 64 return 65 65 } 66 66 67 - err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 67 + err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey) 68 68 if err != nil { 69 69 log.Println("failed to react", err) 70 70 return ··· 87 87 88 88 return 89 89 case http.MethodDelete: 90 - reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 90 + reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind) 91 91 if err != nil { 92 - log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 92 + log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri) 93 93 return 94 94 } 95 95 96 96 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 97 Collection: tangled.FeedReactionNSID, 98 - Repo: currentUser.Did, 98 + Repo: currentUser.Active.Did, 99 99 Rkey: reaction.Rkey, 100 100 }) 101 101 ··· 104 104 return 105 105 } 106 106 107 - err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 107 + err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey) 108 108 if err != nil { 109 109 log.Println("failed to delete reaction from DB") 110 110 // this is not an issue, the firehose event might have already done this
+7
appview/state/router.go
··· 109 109 }) 110 110 111 111 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 112 + w.WriteHeader(http.StatusNotFound) 112 113 s.pages.Error404(w) 113 114 }) 114 115 ··· 130 131 r.Get("/login", s.Login) 131 132 r.Post("/login", s.Login) 132 133 r.Post("/logout", s.Logout) 134 + 135 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/account", func(r chi.Router) { 136 + r.Post("/switch", s.SwitchAccount) 137 + r.Delete("/{did}", s.RemoveAccount) 138 + }) 133 139 134 140 r.Route("/repo", func(r chi.Router) { 135 141 r.Route("/new", func(r chi.Router) { ··· 182 188 r.Get("/brand", s.Brand) 183 189 184 190 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 191 + w.WriteHeader(http.StatusNotFound) 185 192 s.pages.Error404(w) 186 193 }) 187 194 return r
+6 -6
appview/state/star.go
··· 16 16 ) 17 17 18 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 19 - currentUser := s.oauth.GetUser(r) 19 + currentUser := s.oauth.GetMultiAccountUser(r) 20 20 21 21 subject := r.URL.Query().Get("subject") 22 22 if subject == "" { ··· 42 42 rkey := tid.TID() 43 43 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 - Repo: currentUser.Did, 45 + Repo: currentUser.Active.Did, 46 46 Rkey: rkey, 47 47 Record: &lexutil.LexiconTypeDecoder{ 48 48 Val: &tangled.FeedStar{ ··· 57 57 log.Println("created atproto record: ", resp.Uri) 58 58 59 59 star := &models.Star{ 60 - Did: currentUser.Did, 60 + Did: currentUser.Active.Did, 61 61 RepoAt: subjectUri, 62 62 Rkey: rkey, 63 63 } ··· 84 84 return 85 85 case http.MethodDelete: 86 86 // find the record in the db 87 - star, err := db.GetStar(s.db, currentUser.Did, subjectUri) 87 + star, err := db.GetStar(s.db, currentUser.Active.Did, subjectUri) 88 88 if err != nil { 89 89 log.Println("failed to get star relationship") 90 90 return ··· 92 92 93 93 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 94 94 Collection: tangled.FeedStarNSID, 95 - Repo: currentUser.Did, 95 + Repo: currentUser.Active.Did, 96 96 Rkey: star.Rkey, 97 97 }) 98 98 ··· 101 101 return 102 102 } 103 103 104 - err = db.DeleteStarByRkey(s.db, currentUser.Did, star.Rkey) 104 + err = db.DeleteStarByRkey(s.db, currentUser.Active.Did, star.Rkey) 105 105 if err != nil { 106 106 log.Println("failed to delete star from DB") 107 107 // this is not an issue, the firehose event might have already done this
+22 -22
appview/state/state.go
··· 249 249 } 250 250 251 251 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 252 - user := s.oauth.GetUser(r) 252 + user := s.oauth.GetMultiAccountUser(r) 253 253 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 254 254 LoggedInUser: user, 255 255 }) 256 256 } 257 257 258 258 func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 259 - user := s.oauth.GetUser(r) 259 + user := s.oauth.GetMultiAccountUser(r) 260 260 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 261 261 LoggedInUser: user, 262 262 }) 263 263 } 264 264 265 265 func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 266 - user := s.oauth.GetUser(r) 266 + user := s.oauth.GetMultiAccountUser(r) 267 267 s.pages.Brand(w, pages.BrandParams{ 268 268 LoggedInUser: user, 269 269 }) 270 270 } 271 271 272 272 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 273 - if s.oauth.GetUser(r) != nil { 273 + if s.oauth.GetMultiAccountUser(r) != nil { 274 274 s.Timeline(w, r) 275 275 return 276 276 } ··· 278 278 } 279 279 280 280 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 281 - user := s.oauth.GetUser(r) 281 + user := s.oauth.GetMultiAccountUser(r) 282 282 283 283 // TODO: set this flag based on the UI 284 284 filtered := false 285 285 286 286 var userDid string 287 - if user != nil { 288 - userDid = user.Did 287 + if user != nil && user.Active != nil { 288 + userDid = user.Active.Did 289 289 } 290 290 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 291 291 if err != nil { ··· 314 314 } 315 315 316 316 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 317 - user := s.oauth.GetUser(r) 317 + user := s.oauth.GetMultiAccountUser(r) 318 318 if user == nil { 319 319 return 320 320 } 321 321 322 322 l := s.logger.With("handler", "UpgradeBanner") 323 - l = l.With("did", user.Did) 323 + l = l.With("did", user.Active.Did) 324 324 325 325 regs, err := db.GetRegistrations( 326 326 s.db, 327 - orm.FilterEq("did", user.Did), 327 + orm.FilterEq("did", user.Active.Did), 328 328 orm.FilterEq("needs_upgrade", 1), 329 329 ) 330 330 if err != nil { ··· 333 333 334 334 spindles, err := db.GetSpindles( 335 335 s.db, 336 - orm.FilterEq("owner", user.Did), 336 + orm.FilterEq("owner", user.Active.Did), 337 337 orm.FilterEq("needs_upgrade", 1), 338 338 ) 339 339 if err != nil { ··· 447 447 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 448 448 switch r.Method { 449 449 case http.MethodGet: 450 - user := s.oauth.GetUser(r) 451 - knots, err := s.enforcer.GetKnotsForUser(user.Did) 450 + user := s.oauth.GetMultiAccountUser(r) 451 + knots, err := s.enforcer.GetKnotsForUser(user.Active.Did) 452 452 if err != nil { 453 453 s.pages.Notice(w, "repo", "Invalid user account.") 454 454 return ··· 462 462 case http.MethodPost: 463 463 l := s.logger.With("handler", "NewRepo") 464 464 465 - user := s.oauth.GetUser(r) 466 - l = l.With("did", user.Did) 465 + user := s.oauth.GetMultiAccountUser(r) 466 + l = l.With("did", user.Active.Did) 467 467 468 468 // form validation 469 469 domain := r.FormValue("domain") ··· 495 495 description := r.FormValue("description") 496 496 497 497 // ACL validation 498 - ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 498 + ok, err := s.enforcer.E.Enforce(user.Active.Did, domain, domain, "repo:create") 499 499 if err != nil || !ok { 500 500 l.Info("unauthorized") 501 501 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") ··· 505 505 // Check for existing repos 506 506 existingRepo, err := db.GetRepo( 507 507 s.db, 508 - orm.FilterEq("did", user.Did), 508 + orm.FilterEq("did", user.Active.Did), 509 509 orm.FilterEq("name", repoName), 510 510 ) 511 511 if err == nil && existingRepo != nil { ··· 517 517 // create atproto record for this repo 518 518 rkey := tid.TID() 519 519 repo := &models.Repo{ 520 - Did: user.Did, 520 + Did: user.Active.Did, 521 521 Name: repoName, 522 522 Knot: domain, 523 523 Rkey: rkey, ··· 536 536 537 537 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 538 538 Collection: tangled.RepoNSID, 539 - Repo: user.Did, 539 + Repo: user.Active.Did, 540 540 Rkey: rkey, 541 541 Record: &lexutil.LexiconTypeDecoder{ 542 542 Val: &record, ··· 613 613 } 614 614 615 615 // acls 616 - p, _ := securejoin.SecureJoin(user.Did, repoName) 617 - err = s.enforcer.AddRepo(user.Did, domain, p) 616 + p, _ := securejoin.SecureJoin(user.Active.Did, repoName) 617 + err = s.enforcer.AddRepo(user.Active.Did, domain, p) 618 618 if err != nil { 619 619 l.Error("acl setup failed", "err", err) 620 620 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 639 639 aturi = "" 640 640 641 641 s.notifier.NewRepo(r.Context(), repo) 642 - s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 642 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, repoName)) 643 643 } 644 644 } 645 645
+19 -19
appview/strings/strings.go
··· 82 82 } 83 83 84 84 s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 85 - LoggedInUser: s.OAuth.GetUser(r), 85 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 86 86 Strings: strings, 87 87 }) 88 88 } ··· 153 153 if err != nil { 154 154 l.Error("failed to get star count", "err", err) 155 155 } 156 - user := s.OAuth.GetUser(r) 156 + user := s.OAuth.GetMultiAccountUser(r) 157 157 isStarred := false 158 158 if user != nil { 159 - isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 159 + isStarred = db.GetStarStatus(s.Db, user.Active.Did, string.AtUri()) 160 160 } 161 161 162 162 s.Pages.SingleString(w, pages.SingleStringParams{ ··· 178 178 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 179 179 l := s.Logger.With("handler", "edit") 180 180 181 - user := s.OAuth.GetUser(r) 181 + user := s.OAuth.GetMultiAccountUser(r) 182 182 183 183 id, ok := r.Context().Value("resolvedId").(identity.Identity) 184 184 if !ok { ··· 216 216 first := all[0] 217 217 218 218 // verify that the logged in user owns this string 219 - if user.Did != id.DID.String() { 220 - l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 219 + if user.Active.Did != id.DID.String() { 220 + l.Error("unauthorized request", "expected", id.DID, "got", user.Active.Did) 221 221 w.WriteHeader(http.StatusUnauthorized) 222 222 return 223 223 } ··· 226 226 case http.MethodGet: 227 227 // return the form with prefilled fields 228 228 s.Pages.PutString(w, pages.PutStringParams{ 229 - LoggedInUser: s.OAuth.GetUser(r), 229 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 230 230 Action: "edit", 231 231 String: first, 232 232 }) ··· 299 299 s.Notifier.EditString(r.Context(), &entry) 300 300 301 301 // if that went okay, redir to the string 302 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 302 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+entry.Rkey) 303 303 } 304 304 305 305 } 306 306 307 307 func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 308 308 l := s.Logger.With("handler", "create") 309 - user := s.OAuth.GetUser(r) 309 + user := s.OAuth.GetMultiAccountUser(r) 310 310 311 311 switch r.Method { 312 312 case http.MethodGet: 313 313 s.Pages.PutString(w, pages.PutStringParams{ 314 - LoggedInUser: s.OAuth.GetUser(r), 314 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 315 315 Action: "new", 316 316 }) 317 317 case http.MethodPost: ··· 335 335 description := r.FormValue("description") 336 336 337 337 string := models.String{ 338 - Did: syntax.DID(user.Did), 338 + Did: syntax.DID(user.Active.Did), 339 339 Rkey: tid.TID(), 340 340 Filename: filename, 341 341 Description: description, ··· 353 353 354 354 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 355 355 Collection: tangled.StringNSID, 356 - Repo: user.Did, 356 + Repo: user.Active.Did, 357 357 Rkey: string.Rkey, 358 358 Record: &lexutil.LexiconTypeDecoder{ 359 359 Val: &record, ··· 375 375 s.Notifier.NewString(r.Context(), &string) 376 376 377 377 // successful 378 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 378 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+string.Rkey) 379 379 } 380 380 } 381 381 382 382 func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 383 383 l := s.Logger.With("handler", "create") 384 - user := s.OAuth.GetUser(r) 384 + user := s.OAuth.GetMultiAccountUser(r) 385 385 fail := func(msg string, err error) { 386 386 l.Error(msg, "err", err) 387 387 s.Pages.Notice(w, "error", msg) ··· 402 402 return 403 403 } 404 404 405 - if user.Did != id.DID.String() { 406 - fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 405 + if user.Active.Did != id.DID.String() { 406 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Active.Did, id.DID.String())) 407 407 return 408 408 } 409 409 410 410 if err := db.DeleteString( 411 411 s.Db, 412 - orm.FilterEq("did", user.Did), 412 + orm.FilterEq("did", user.Active.Did), 413 413 orm.FilterEq("rkey", rkey), 414 414 ); err != nil { 415 415 fail("Failed to delete string.", err) 416 416 return 417 417 } 418 418 419 - s.Notifier.DeleteString(r.Context(), user.Did, rkey) 419 + s.Notifier.DeleteString(r.Context(), user.Active.Did, rkey) 420 420 421 - s.Pages.HxRedirect(w, "/strings/"+user.Did) 421 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did) 422 422 } 423 423 424 424 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+1527
docs/DOCS.md
··· 1 + --- 2 + title: Tangled docs 3 + author: The Tangled Contributors 4 + date: 21 Sun, Dec 2025 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 10 + 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 17 + 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 25 + 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 31 + 32 + # Quick start guide 33 + 34 + ## Login or sign up 35 + 36 + You can [login](https://tangled.org) by using your AT Protocol 37 + account. If you are unclear on what that means, simply head 38 + to the [signup](https://tangled.org/signup) page and create 39 + an account. By doing so, you will be choosing Tangled as 40 + your account provider (you will be granted a handle of the 41 + form `user.tngl.sh`). 42 + 43 + In the AT Protocol network, users are free to choose their account 44 + provider (known as a "Personal Data Service", or PDS), and 45 + login to applications that support AT accounts. 46 + 47 + You can think of it as "one account for all of the atmosphere"! 48 + 49 + If you already have an AT account (you may have one if you 50 + signed up to Bluesky, for example), you can login with the 51 + same handle on Tangled (so just use `user.bsky.social` on 52 + the login page). 53 + 54 + ## Add an SSH key 55 + 56 + Once you are logged in, you can start creating repositories 57 + and pushing code. Tangled supports pushing git repositories 58 + over SSH. 59 + 60 + First, you'll need to generate an SSH key if you don't 61 + already have one: 62 + 63 + ```bash 64 + ssh-keygen -t ed25519 -C "foo@bar.com" 65 + ``` 66 + 67 + When prompted, save the key to the default location 68 + (`~/.ssh/id_ed25519`) and optionally set a passphrase. 69 + 70 + Copy your public key to your clipboard: 71 + 72 + ```bash 73 + # on X11 74 + cat ~/.ssh/id_ed25519.pub | xclip -sel c 75 + 76 + # on wayland 77 + cat ~/.ssh/id_ed25519.pub | wl-copy 78 + 79 + # on macos 80 + cat ~/.ssh/id_ed25519.pub | pbcopy 81 + ``` 82 + 83 + Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 84 + paste your public key, give it a descriptive name, and hit 85 + save. 86 + 87 + ## Create a repository 88 + 89 + Once your SSH key is added, create your first repository: 90 + 91 + 1. Hit the green `+` icon on the topbar, and select 92 + repository 93 + 2. Enter a repository name 94 + 3. Add a description 95 + 4. Choose a knotserver to host this repository on 96 + 5. Hit create 97 + 98 + Knots are self-hostable, lightweight Git servers that can 99 + host your repository. Unlike traditional code forges, your 100 + code can live on any server. Read the [Knots](TODO) section 101 + for more. 102 + 103 + ## Configure SSH 104 + 105 + To ensure Git uses the correct SSH key and connects smoothly 106 + to Tangled, add this configuration to your `~/.ssh/config` 107 + file: 108 + 109 + ``` 110 + Host tangled.org 111 + Hostname tangled.org 112 + User git 113 + IdentityFile ~/.ssh/id_ed25519 114 + AddressFamily inet 115 + ``` 116 + 117 + This tells SSH to use your specific key when connecting to 118 + Tangled and prevents authentication issues if you have 119 + multiple SSH keys. 120 + 121 + Note that this configuration only works for knotservers that 122 + are hosted by tangled.org. If you use a custom knot, refer 123 + to the [Knots](TODO) section. 124 + 125 + ## Push your first repository 126 + 127 + Initialize a new Git repository: 128 + 129 + ```bash 130 + mkdir my-project 131 + cd my-project 132 + 133 + git init 134 + echo "# My Project" > README.md 135 + ``` 136 + 137 + Add some content and push! 138 + 139 + ```bash 140 + git add README.md 141 + git commit -m "Initial commit" 142 + git remote add origin git@tangled.org:user.tngl.sh/my-project 143 + git push -u origin main 144 + ``` 145 + 146 + That's it! Your code is now hosted on Tangled. 147 + 148 + ## Migrating an existing repository 149 + 150 + Moving your repositories from GitHub, GitLab, Bitbucket, or 151 + any other Git forge to Tangled is straightforward. You'll 152 + simply change your repository's remote URL. At the moment, 153 + Tangled does not have any tooling to migrate data such as 154 + GitHub issues or pull requests. 155 + 156 + First, create a new repository on tangled.org as described 157 + in the [Quick Start Guide](#create-a-repository). 158 + 159 + Navigate to your existing local repository: 160 + 161 + ```bash 162 + cd /path/to/your/existing/repo 163 + ``` 164 + 165 + You can inspect your existing Git remote like so: 166 + 167 + ```bash 168 + git remote -v 169 + ``` 170 + 171 + You'll see something like: 172 + 173 + ``` 174 + origin git@github.com:username/my-project (fetch) 175 + origin git@github.com:username/my-project (push) 176 + ``` 177 + 178 + Update the remote URL to point to tangled: 179 + 180 + ```bash 181 + git remote set-url origin git@tangled.org:user.tngl.sh/my-project 182 + ``` 183 + 184 + Verify the change: 185 + 186 + ```bash 187 + git remote -v 188 + ``` 189 + 190 + You should now see: 191 + 192 + ``` 193 + origin git@tangled.org:user.tngl.sh/my-project (fetch) 194 + origin git@tangled.org:user.tngl.sh/my-project (push) 195 + ``` 196 + 197 + Push all your branches and tags to Tangled: 198 + 199 + ```bash 200 + git push -u origin --all 201 + git push -u origin --tags 202 + ``` 203 + 204 + Your repository is now migrated to Tangled! All commit 205 + history, branches, and tags have been preserved. 206 + 207 + ## Mirroring a repository to Tangled 208 + 209 + If you want to maintain your repository on multiple forges 210 + simultaneously, for example, keeping your primary repository 211 + on GitHub while mirroring to Tangled for backup or 212 + redundancy, you can do so by adding multiple remotes. 213 + 214 + You can configure your local repository to push to both 215 + Tangled and, say, GitHub. You may already have the following 216 + setup: 217 + 218 + ``` 219 + $ git remote -v 220 + origin git@github.com:username/my-project (fetch) 221 + origin git@github.com:username/my-project (push) 222 + ``` 223 + 224 + Now add Tangled as an additional push URL to the same 225 + remote: 226 + 227 + ```bash 228 + git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 229 + ``` 230 + 231 + You also need to re-add the original URL as a push 232 + destination (Git replaces the push URL when you use `--add` 233 + the first time): 234 + 235 + ```bash 236 + git remote set-url --add --push origin git@github.com:username/my-project 237 + ``` 238 + 239 + Verify your configuration: 240 + 241 + ``` 242 + $ git remote -v 243 + origin git@github.com:username/repo (fetch) 244 + origin git@tangled.org:username/my-project (push) 245 + origin git@github.com:username/repo (push) 246 + ``` 247 + 248 + Notice that there's one fetch URL (the primary remote) and 249 + two push URLs. Now, whenever you push, Git will 250 + automatically push to both remotes: 251 + 252 + ```bash 253 + git push origin main 254 + ``` 255 + 256 + This single command pushes your `main` branch to both GitHub 257 + and Tangled simultaneously. 258 + 259 + To push all branches and tags: 260 + 261 + ```bash 262 + git push origin --all 263 + git push origin --tags 264 + ``` 265 + 266 + If you prefer more control over which remote you push to, 267 + you can maintain separate remotes: 268 + 269 + ```bash 270 + git remote add github git@github.com:username/my-project 271 + git remote add tangled git@tangled.org:username/my-project 272 + ``` 273 + 274 + Then push to each explicitly: 275 + 276 + ```bash 277 + git push github main 278 + git push tangled main 279 + ``` 280 + 281 + # Knot self-hosting guide 282 + 283 + So you want to run your own knot server? Great! Here are a few prerequisites: 284 + 285 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 286 + 2. A (sub)domain name. People generally use `knot.example.com`. 287 + 3. A valid SSL certificate for your domain. 288 + 289 + ## NixOS 290 + 291 + Refer to the [knot 292 + module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 293 + for a full list of options. Sample configurations: 294 + 295 + - [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 296 + - [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25) 297 + 298 + ## Docker 299 + 300 + Refer to 301 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 302 + Note that this is community maintained. 303 + 304 + ## Manual setup 305 + 306 + First, clone this repository: 307 + 308 + ``` 309 + git clone https://tangled.org/@tangled.org/core 310 + ``` 311 + 312 + Then, build the `knot` CLI. This is the knot administration 313 + and operation tool. For the purpose of this guide, we're 314 + only concerned with these subcommands: 315 + 316 + * `knot server`: the main knot server process, typically 317 + run as a supervised service 318 + * `knot guard`: handles role-based access control for git 319 + over SSH (you'll never have to run this yourself) 320 + * `knot keys`: fetches SSH keys associated with your knot; 321 + we'll use this to generate the SSH 322 + `AuthorizedKeysCommand` 323 + 324 + ``` 325 + cd core 326 + export CGO_ENABLED=1 327 + go build -o knot ./cmd/knot 328 + ``` 329 + 330 + Next, move the `knot` binary to a location owned by `root` -- 331 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 332 + 333 + ``` 334 + sudo mv knot /usr/local/bin/knot 335 + sudo chown root:root /usr/local/bin/knot 336 + ``` 337 + 338 + This is necessary because SSH `AuthorizedKeysCommand` requires [really 339 + specific permissions](https://stackoverflow.com/a/27638306). The 340 + `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 341 + retrieve a user's public SSH keys dynamically for authentication. Let's 342 + set that up. 343 + 344 + ``` 345 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 346 + Match User git 347 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 348 + AuthorizedKeysCommandUser nobody 349 + EOF 350 + ``` 351 + 352 + Then, reload `sshd`: 353 + 354 + ``` 355 + sudo systemctl reload ssh 356 + ``` 357 + 358 + Next, create the `git` user. We'll use the `git` user's home directory 359 + to store repositories: 360 + 361 + ``` 362 + sudo adduser git 363 + ``` 364 + 365 + Create `/home/git/.knot.env` with the following, updating the values as 366 + necessary. The `KNOT_SERVER_OWNER` should be set to your 367 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 368 + 369 + ``` 370 + KNOT_REPO_SCAN_PATH=/home/git 371 + KNOT_SERVER_HOSTNAME=knot.example.com 372 + APPVIEW_ENDPOINT=https://tangled.org 373 + KNOT_SERVER_OWNER=did:plc:foobar 374 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 375 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 + ``` 377 + 378 + If you run a Linux distribution that uses systemd, you can use the provided 379 + service file to run the server. Copy 380 + [`knotserver.service`](/systemd/knotserver.service) 381 + to `/etc/systemd/system/`. Then, run: 382 + 383 + ``` 384 + systemctl enable knotserver 385 + systemctl start knotserver 386 + ``` 387 + 388 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 389 + knot. Here's an example configuration for Nginx: 390 + 391 + ``` 392 + server { 393 + listen 80; 394 + listen [::]:80; 395 + server_name knot.example.com; 396 + 397 + location / { 398 + proxy_pass http://localhost:5555; 399 + proxy_set_header Host $host; 400 + proxy_set_header X-Real-IP $remote_addr; 401 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 402 + proxy_set_header X-Forwarded-Proto $scheme; 403 + } 404 + 405 + # wss endpoint for git events 406 + location /events { 407 + proxy_set_header X-Forwarded-For $remote_addr; 408 + proxy_set_header Host $http_host; 409 + proxy_set_header Upgrade websocket; 410 + proxy_set_header Connection Upgrade; 411 + proxy_pass http://localhost:5555; 412 + } 413 + # additional config for SSL/TLS go here. 414 + } 415 + 416 + ``` 417 + 418 + Remember to use Let's Encrypt or similar to procure a certificate for your 419 + knot domain. 420 + 421 + You should now have a running knot server! You can finalize 422 + your registration by hitting the `verify` button on the 423 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 424 + a record on your PDS to announce the existence of the knot. 425 + 426 + ### Custom paths 427 + 428 + (This section applies to manual setup only. Docker users should edit the mounts 429 + in `docker-compose.yml` instead.) 430 + 431 + Right now, the database and repositories of your knot lives in `/home/git`. You 432 + can move these paths if you'd like to store them in another folder. Be careful 433 + when adjusting these paths: 434 + 435 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 + any possible side effects. Remember to restart it once you're done. 437 + * Make backups before moving in case something goes wrong. 438 + * Make sure the `git` user can read and write from the new paths. 439 + 440 + #### Database 441 + 442 + As an example, let's say the current database is at `/home/git/knotserver.db`, 443 + and we want to move it to `/home/git/database/knotserver.db`. 444 + 445 + Copy the current database to the new location. Make sure to copy the `.db-shm` 446 + and `.db-wal` files if they exist. 447 + 448 + ``` 449 + mkdir /home/git/database 450 + cp /home/git/knotserver.db* /home/git/database 451 + ``` 452 + 453 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 454 + the new file path (_not_ the directory): 455 + 456 + ``` 457 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 458 + ``` 459 + 460 + #### Repositories 461 + 462 + As an example, let's say the repositories are currently in `/home/git`, and we 463 + want to move them into `/home/git/repositories`. 464 + 465 + Create the new folder, then move the existing repositories (if there are any): 466 + 467 + ``` 468 + mkdir /home/git/repositories 469 + # move all DIDs into the new folder; these will vary for you! 470 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 471 + ``` 472 + 473 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 474 + to the new directory: 475 + 476 + ``` 477 + KNOT_REPO_SCAN_PATH=/home/git/repositories 478 + ``` 479 + 480 + Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 481 + repository path: 482 + 483 + ``` 484 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 485 + Match User git 486 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 487 + AuthorizedKeysCommandUser nobody 488 + EOF 489 + ``` 490 + 491 + Make sure to restart your SSH server! 492 + 493 + #### MOTD (message of the day) 494 + 495 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 496 + `/home/git/motd` file: 497 + 498 + ``` 499 + printf "Hi from this knot!\n" > /home/git/motd 500 + ``` 501 + 502 + Note that you should add a newline at the end if setting a non-empty message 503 + since the knot won't do this for you. 504 + 505 + # Spindles 506 + 507 + ## Pipelines 508 + 509 + Spindle workflows allow you to write CI/CD pipelines in a 510 + simple format. They're located in the `.tangled/workflows` 511 + directory at the root of your repository, and are defined 512 + using YAML. 513 + 514 + The fields are: 515 + 516 + - [Trigger](#trigger): A **required** field that defines 517 + when a workflow should be triggered. 518 + - [Engine](#engine): A **required** field that defines which 519 + engine a workflow should run on. 520 + - [Clone options](#clone-options): An **optional** field 521 + that defines how the repository should be cloned. 522 + - [Dependencies](#dependencies): An **optional** field that 523 + allows you to list dependencies you may need. 524 + - [Environment](#environment): An **optional** field that 525 + allows you to define environment variables. 526 + - [Steps](#steps): An **optional** field that allows you to 527 + define what steps should run in the workflow. 528 + 529 + ### Trigger 530 + 531 + The first thing to add to a workflow is the trigger, which 532 + defines when a workflow runs. This is defined using a `when` 533 + field, which takes in a list of conditions. Each condition 534 + has the following fields: 535 + 536 + - `event`: This is a **required** field that defines when 537 + your workflow should run. It's a list that can take one or 538 + more of the following values: 539 + - `push`: The workflow should run every time a commit is 540 + pushed to the repository. 541 + - `pull_request`: The workflow should run every time a 542 + pull request is made or updated. 543 + - `manual`: The workflow can be triggered manually. 544 + - `branch`: Defines which branches the workflow should run 545 + for. If used with the `push` event, commits to the 546 + branch(es) listed here will trigger the workflow. If used 547 + with the `pull_request` event, updates to pull requests 548 + targeting the branch(es) listed here will trigger the 549 + workflow. This field has no effect with the `manual` 550 + event. Supports glob patterns using `*` and `**` (e.g., 551 + `main`, `develop`, `release-*`). Either `branch` or `tag` 552 + (or both) must be specified for `push` events. 553 + - `tag`: Defines which tags the workflow should run for. 554 + Only used with the `push` event - when tags matching the 555 + pattern(s) listed here are pushed, the workflow will 556 + trigger. This field has no effect with `pull_request` or 557 + `manual` events. Supports glob patterns using `*` and `**` 558 + (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 559 + `tag` (or both) must be specified for `push` events. 560 + 561 + For example, if you'd like to define a workflow that runs 562 + when commits are pushed to the `main` and `develop` 563 + branches, or when pull requests that target the `main` 564 + branch are updated, or manually, you can do so with: 565 + 566 + ```yaml 567 + when: 568 + - event: ["push", "manual"] 569 + branch: ["main", "develop"] 570 + - event: ["pull_request"] 571 + branch: ["main"] 572 + ``` 573 + 574 + You can also trigger workflows on tag pushes. For instance, 575 + to run a deployment workflow when tags matching `v*` are 576 + pushed: 577 + 578 + ```yaml 579 + when: 580 + - event: ["push"] 581 + tag: ["v*"] 582 + ``` 583 + 584 + You can even combine branch and tag patterns in a single 585 + constraint (the workflow triggers if either matches): 586 + 587 + ```yaml 588 + when: 589 + - event: ["push"] 590 + branch: ["main", "release-*"] 591 + tag: ["v*", "stable"] 592 + ``` 593 + 594 + ### Engine 595 + 596 + Next is the engine on which the workflow should run, defined 597 + using the **required** `engine` field. The currently 598 + supported engines are: 599 + 600 + - `nixery`: This uses an instance of 601 + [Nixery](https://nixery.dev) to run steps, which allows 602 + you to add [dependencies](#dependencies) from 603 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 604 + search for packages on https://search.nixos.org, and 605 + there's a pretty good chance the package(s) you're looking 606 + for will be there. 607 + 608 + Example: 609 + 610 + ```yaml 611 + engine: "nixery" 612 + ``` 613 + 614 + ### Clone options 615 + 616 + When a workflow starts, the first step is to clone the 617 + repository. You can customize this behavior using the 618 + **optional** `clone` field. It has the following fields: 619 + 620 + - `skip`: Setting this to `true` will skip cloning the 621 + repository. This can be useful if your workflow is doing 622 + something that doesn't require anything from the 623 + repository itself. This is `false` by default. 624 + - `depth`: This sets the number of commits, or the "clone 625 + depth", to fetch from the repository. For example, if you 626 + set this to 2, the last 2 commits will be fetched. By 627 + default, the depth is set to 1, meaning only the most 628 + recent commit will be fetched, which is the commit that 629 + triggered the workflow. 630 + - `submodules`: If you use Git submodules 631 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 632 + in your repository, setting this field to `true` will 633 + recursively fetch all submodules. This is `false` by 634 + default. 635 + 636 + The default settings are: 637 + 638 + ```yaml 639 + clone: 640 + skip: false 641 + depth: 1 642 + submodules: false 643 + ``` 644 + 645 + ### Dependencies 646 + 647 + Usually when you're running a workflow, you'll need 648 + additional dependencies. The `dependencies` field lets you 649 + define which dependencies to get, and from where. It's a 650 + key-value map, with the key being the registry to fetch 651 + dependencies from, and the value being the list of 652 + dependencies to fetch. 653 + 654 + Say you want to fetch Node.js and Go from `nixpkgs`, and a 655 + package called `my_pkg` you've made from your own registry 656 + at your repository at 657 + `https://tangled.org/@example.com/my_pkg`. You can define 658 + those dependencies like so: 659 + 660 + ```yaml 661 + dependencies: 662 + # nixpkgs 663 + nixpkgs: 664 + - nodejs 665 + - go 666 + # custom registry 667 + git+https://tangled.org/@example.com/my_pkg: 668 + - my_pkg 669 + ``` 670 + 671 + Now these dependencies are available to use in your 672 + workflow! 673 + 674 + ### Environment 675 + 676 + The `environment` field allows you define environment 677 + variables that will be available throughout the entire 678 + workflow. **Do not put secrets here, these environment 679 + variables are visible to anyone viewing the repository. You 680 + can add secrets for pipelines in your repository's 681 + settings.** 682 + 683 + Example: 684 + 685 + ```yaml 686 + environment: 687 + GOOS: "linux" 688 + GOARCH: "arm64" 689 + NODE_ENV: "production" 690 + MY_ENV_VAR: "MY_ENV_VALUE" 691 + ``` 692 + 693 + ### Steps 694 + 695 + The `steps` field allows you to define what steps should run 696 + in the workflow. It's a list of step objects, each with the 697 + following fields: 698 + 699 + - `name`: This field allows you to give your step a name. 700 + This name is visible in your workflow runs, and is used to 701 + describe what the step is doing. 702 + - `command`: This field allows you to define a command to 703 + run in that step. The step is run in a Bash shell, and the 704 + logs from the command will be visible in the pipelines 705 + page on the Tangled website. The 706 + [dependencies](#dependencies) you added will be available 707 + to use here. 708 + - `environment`: Similar to the global 709 + [environment](#environment) config, this **optional** 710 + field is a key-value map that allows you to set 711 + environment variables for the step. **Do not put secrets 712 + here, these environment variables are visible to anyone 713 + viewing the repository. You can add secrets for pipelines 714 + in your repository's settings.** 715 + 716 + Example: 717 + 718 + ```yaml 719 + steps: 720 + - name: "Build backend" 721 + command: "go build" 722 + environment: 723 + GOOS: "darwin" 724 + GOARCH: "arm64" 725 + - name: "Build frontend" 726 + command: "npm run build" 727 + environment: 728 + NODE_ENV: "production" 729 + ``` 730 + 731 + ### Complete workflow 732 + 733 + ```yaml 734 + # .tangled/workflows/build.yml 735 + 736 + when: 737 + - event: ["push", "manual"] 738 + branch: ["main", "develop"] 739 + - event: ["pull_request"] 740 + branch: ["main"] 741 + 742 + engine: "nixery" 743 + 744 + # using the default values 745 + clone: 746 + skip: false 747 + depth: 1 748 + submodules: false 749 + 750 + dependencies: 751 + # nixpkgs 752 + nixpkgs: 753 + - nodejs 754 + - go 755 + # custom registry 756 + git+https://tangled.org/@example.com/my_pkg: 757 + - my_pkg 758 + 759 + environment: 760 + GOOS: "linux" 761 + GOARCH: "arm64" 762 + NODE_ENV: "production" 763 + MY_ENV_VAR: "MY_ENV_VALUE" 764 + 765 + steps: 766 + - name: "Build backend" 767 + command: "go build" 768 + environment: 769 + GOOS: "darwin" 770 + GOARCH: "arm64" 771 + - name: "Build frontend" 772 + command: "npm run build" 773 + environment: 774 + NODE_ENV: "production" 775 + ``` 776 + 777 + If you want another example of a workflow, you can look at 778 + the one [Tangled uses to build the 779 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 780 + 781 + ## Self-hosting guide 782 + 783 + ### Prerequisites 784 + 785 + * Go 786 + * Docker (the only supported backend currently) 787 + 788 + ### Configuration 789 + 790 + Spindle is configured using environment variables. The following environment variables are available: 791 + 792 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 793 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 794 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 795 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 796 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 797 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 798 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 799 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 800 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 801 + 802 + ### Running spindle 803 + 804 + 1. **Set the environment variables.** For example: 805 + 806 + ```shell 807 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 808 + export SPINDLE_SERVER_OWNER="your-did" 809 + ``` 810 + 811 + 2. **Build the Spindle binary.** 812 + 813 + ```shell 814 + cd core 815 + go mod download 816 + go build -o cmd/spindle/spindle cmd/spindle/main.go 817 + ``` 818 + 819 + 3. **Create the log directory.** 820 + 821 + ```shell 822 + sudo mkdir -p /var/log/spindle 823 + sudo chown $USER:$USER -R /var/log/spindle 824 + ``` 825 + 826 + 4. **Run the Spindle binary.** 827 + 828 + ```shell 829 + ./cmd/spindle/spindle 830 + ``` 831 + 832 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 833 + 834 + ## Architecture 835 + 836 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 837 + 838 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 839 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 840 + * When a new repo record comes through (typically when you add a spindle to a 841 + repo from the settings), spindle then resolves the underlying knot and 842 + subscribes to repo events (see: 843 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 844 + * The spindle engine then handles execution of the pipeline, with results and 845 + logs beamed on the spindle event stream over WebSocket 846 + 847 + ### The engine 848 + 849 + At present, the only supported backend is Docker (and Podman, if Docker 850 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 851 + executes each step in the pipeline in a fresh container, with state persisted 852 + across steps within the `/tangled/workspace` directory. 853 + 854 + The base image for the container is constructed on the fly using 855 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 856 + used packages. 857 + 858 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 859 + 860 + ## Secrets with openbao 861 + 862 + This document covers setting up spindle to use OpenBao for secrets 863 + management via OpenBao Proxy instead of the default SQLite backend. 864 + 865 + ### Overview 866 + 867 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 868 + authentication automatically using AppRole credentials, while spindle 869 + connects to the local proxy instead of directly to the OpenBao server. 870 + 871 + This approach provides better security, automatic token renewal, and 872 + simplified application code. 873 + 874 + ### Installation 875 + 876 + Install OpenBao from Nixpkgs: 877 + 878 + ```bash 879 + nix shell nixpkgs#openbao # for a local server 880 + ``` 881 + 882 + ### Setup 883 + 884 + The setup process can is documented for both local development and production. 885 + 886 + #### Local development 887 + 888 + Start OpenBao in dev mode: 889 + 890 + ```bash 891 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 892 + ``` 893 + 894 + This starts OpenBao on `http://localhost:8201` with a root token. 895 + 896 + Set up environment for bao CLI: 897 + 898 + ```bash 899 + export BAO_ADDR=http://localhost:8200 900 + export BAO_TOKEN=root 901 + ``` 902 + 903 + #### Production 904 + 905 + You would typically use a systemd service with a 906 + configuration file. Refer to 907 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) 908 + for how this can be achieved using Nix. 909 + 910 + Then, initialize the bao server: 911 + 912 + ```bash 913 + bao operator init -key-shares=1 -key-threshold=1 914 + ``` 915 + 916 + This will print out an unseal key and a root key. Save them 917 + somewhere (like a password manager). Then unseal the vault 918 + to begin setting it up: 919 + 920 + ```bash 921 + bao operator unseal <unseal_key> 922 + ``` 923 + 924 + All steps below remain the same across both dev and 925 + production setups. 926 + 927 + #### Configure openbao server 928 + 929 + Create the spindle KV mount: 930 + 931 + ```bash 932 + bao secrets enable -path=spindle -version=2 kv 933 + ``` 934 + 935 + Set up AppRole authentication and policy: 936 + 937 + Create a policy file `spindle-policy.hcl`: 938 + 939 + ```hcl 940 + # Full access to spindle KV v2 data 941 + path "spindle/data/*" { 942 + capabilities = ["create", "read", "update", "delete"] 943 + } 944 + 945 + # Access to metadata for listing and management 946 + path "spindle/metadata/*" { 947 + capabilities = ["list", "read", "delete", "update"] 948 + } 949 + 950 + # Allow listing at root level 951 + path "spindle/" { 952 + capabilities = ["list"] 953 + } 954 + 955 + # Required for connection testing and health checks 956 + path "auth/token/lookup-self" { 957 + capabilities = ["read"] 958 + } 959 + ``` 960 + 961 + Apply the policy and create an AppRole: 962 + 963 + ```bash 964 + bao policy write spindle-policy spindle-policy.hcl 965 + bao auth enable approle 966 + bao write auth/approle/role/spindle \ 967 + token_policies="spindle-policy" \ 968 + token_ttl=1h \ 969 + token_max_ttl=4h \ 970 + bind_secret_id=true \ 971 + secret_id_ttl=0 \ 972 + secret_id_num_uses=0 973 + ``` 974 + 975 + Get the credentials: 976 + 977 + ```bash 978 + # Get role ID (static) 979 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 980 + 981 + # Generate secret ID 982 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 983 + 984 + echo "Role ID: $ROLE_ID" 985 + echo "Secret ID: $SECRET_ID" 986 + ``` 987 + 988 + #### Create proxy configuration 989 + 990 + Create the credential files: 991 + 992 + ```bash 993 + # Create directory for OpenBao files 994 + mkdir -p /tmp/openbao 995 + 996 + # Save credentials 997 + echo "$ROLE_ID" > /tmp/openbao/role-id 998 + echo "$SECRET_ID" > /tmp/openbao/secret-id 999 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1000 + ``` 1001 + 1002 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1003 + 1004 + ```hcl 1005 + # OpenBao server connection 1006 + vault { 1007 + address = "http://localhost:8200" 1008 + } 1009 + 1010 + # Auto-Auth using AppRole 1011 + auto_auth { 1012 + method "approle" { 1013 + mount_path = "auth/approle" 1014 + config = { 1015 + role_id_file_path = "/tmp/openbao/role-id" 1016 + secret_id_file_path = "/tmp/openbao/secret-id" 1017 + } 1018 + } 1019 + 1020 + # Optional: write token to file for debugging 1021 + sink "file" { 1022 + config = { 1023 + path = "/tmp/openbao/token" 1024 + mode = 0640 1025 + } 1026 + } 1027 + } 1028 + 1029 + # Proxy listener for spindle 1030 + listener "tcp" { 1031 + address = "127.0.0.1:8201" 1032 + tls_disable = true 1033 + } 1034 + 1035 + # Enable API proxy with auto-auth token 1036 + api_proxy { 1037 + use_auto_auth_token = true 1038 + } 1039 + 1040 + # Enable response caching 1041 + cache { 1042 + use_auto_auth_token = true 1043 + } 1044 + 1045 + # Logging 1046 + log_level = "info" 1047 + ``` 1048 + 1049 + #### Start the proxy 1050 + 1051 + Start OpenBao Proxy: 1052 + 1053 + ```bash 1054 + bao proxy -config=/tmp/openbao/proxy.hcl 1055 + ``` 1056 + 1057 + The proxy will authenticate with OpenBao and start listening on 1058 + `127.0.0.1:8201`. 1059 + 1060 + #### Configure spindle 1061 + 1062 + Set these environment variables for spindle: 1063 + 1064 + ```bash 1065 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1066 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1067 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1068 + ``` 1069 + 1070 + On startup, spindle will now connect to the local proxy, 1071 + which handles all authentication automatically. 1072 + 1073 + ### Production setup for proxy 1074 + 1075 + For production, you'll want to run the proxy as a service: 1076 + 1077 + Place your production configuration in 1078 + `/etc/openbao/proxy.hcl` with proper TLS settings for the 1079 + vault connection. 1080 + 1081 + ### Verifying setup 1082 + 1083 + Test the proxy directly: 1084 + 1085 + ```bash 1086 + # Check proxy health 1087 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1088 + 1089 + # Test token lookup through proxy 1090 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1091 + ``` 1092 + 1093 + Test OpenBao operations through the server: 1094 + 1095 + ```bash 1096 + # List all secrets 1097 + bao kv list spindle/ 1098 + 1099 + # Add a test secret via the spindle API, then check it exists 1100 + bao kv list spindle/repos/ 1101 + 1102 + # Get a specific secret 1103 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 1104 + ``` 1105 + 1106 + ### How it works 1107 + 1108 + - Spindle connects to OpenBao Proxy on localhost (typically 1109 + port 8200 or 8201) 1110 + - The proxy authenticates with OpenBao using AppRole 1111 + credentials 1112 + - All spindle requests go through the proxy, which injects 1113 + authentication tokens 1114 + - Secrets are stored at 1115 + `spindle/repos/{sanitized_repo_path}/{secret_key}` 1116 + - Repository paths like `did:plc:alice/myrepo` become 1117 + `did_plc_alice_myrepo` 1118 + - The proxy handles all token renewal automatically 1119 + - Spindle no longer manages tokens or authentication 1120 + directly 1121 + 1122 + ### Troubleshooting 1123 + 1124 + **Connection refused**: Check that the OpenBao Proxy is 1125 + running and listening on the configured address. 1126 + 1127 + **403 errors**: Verify the AppRole credentials are correct 1128 + and the policy has the necessary permissions. 1129 + 1130 + **404 route errors**: The spindle KV mount probably doesn't 1131 + existโ€”run the mount creation step again. 1132 + 1133 + **Proxy authentication failures**: Check the proxy logs and 1134 + verify the role-id and secret-id files are readable and 1135 + contain valid credentials. 1136 + 1137 + **Secret not found after writing**: This can indicate policy 1138 + permission issues. Verify the policy includes both 1139 + `spindle/data/*` and `spindle/metadata/*` paths with 1140 + appropriate capabilities. 1141 + 1142 + Check proxy logs: 1143 + 1144 + ```bash 1145 + # If running as systemd service 1146 + journalctl -u openbao-proxy -f 1147 + 1148 + # If running directly, check the console output 1149 + ``` 1150 + 1151 + Test AppRole authentication manually: 1152 + 1153 + ```bash 1154 + bao write auth/approle/login \ 1155 + role_id="$(cat /tmp/openbao/role-id)" \ 1156 + secret_id="$(cat /tmp/openbao/secret-id)" 1157 + ``` 1158 + 1159 + # Migrating knots and spindles 1160 + 1161 + Sometimes, non-backwards compatible changes are made to the 1162 + knot/spindle XRPC APIs. If you host a knot or a spindle, you 1163 + will need to follow this guide to upgrade. Typically, this 1164 + only requires you to deploy the newest version. 1165 + 1166 + This document is laid out in reverse-chronological order. 1167 + Newer migration guides are listed first, and older guides 1168 + are further down the page. 1169 + 1170 + ## Upgrading from v1.8.x 1171 + 1172 + After v1.8.2, the HTTP API for knots and spindles has been 1173 + deprecated and replaced with XRPC. Repositories on outdated 1174 + knots will not be viewable from the appview. Upgrading is 1175 + straightforward however. 1176 + 1177 + For knots: 1178 + 1179 + - Upgrade to the latest tag (v1.9.0 or above) 1180 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1181 + hit the "retry" button to verify your knot 1182 + 1183 + For spindles: 1184 + 1185 + - Upgrade to the latest tag (v1.9.0 or above) 1186 + - Head to the [spindle 1187 + dashboard](https://tangled.org/settings/spindles) and hit the 1188 + "retry" button to verify your spindle 1189 + 1190 + ## Upgrading from v1.7.x 1191 + 1192 + After v1.7.0, knot secrets have been deprecated. You no 1193 + longer need a secret from the appview to run a knot. All 1194 + authorized commands to knots are managed via [Inter-Service 1195 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 1196 + Knots will be read-only until upgraded. 1197 + 1198 + Upgrading is quite easy, in essence: 1199 + 1200 + - `KNOT_SERVER_SECRET` is no more, you can remove this 1201 + environment variable entirely 1202 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 1203 + your DID. You can find your DID in the 1204 + [settings](https://tangled.org/settings) page. 1205 + - Restart your knot once you have replaced the environment 1206 + variable 1207 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1208 + hit the "retry" button to verify your knot. This simply 1209 + writes a `sh.tangled.knot` record to your PDS. 1210 + 1211 + If you use the nix module, simply bump the flake to the 1212 + latest revision, and change your config block like so: 1213 + 1214 + ```diff 1215 + services.tangled.knot = { 1216 + enable = true; 1217 + server = { 1218 + - secretFile = /path/to/secret; 1219 + + owner = "did:plc:foo"; 1220 + }; 1221 + }; 1222 + ``` 1223 + 1224 + # Hacking on Tangled 1225 + 1226 + We highly recommend [installing 1227 + Nix](https://nixos.org/download/) (the package manager) 1228 + before working on the codebase. The Nix flake provides a lot 1229 + of helpers to get started and most importantly, builds and 1230 + dev shells are entirely deterministic. 1231 + 1232 + To set up your dev environment: 1233 + 1234 + ```bash 1235 + nix develop 1236 + ``` 1237 + 1238 + Non-Nix users can look at the `devShell` attribute in the 1239 + `flake.nix` file to determine necessary dependencies. 1240 + 1241 + ## Running the appview 1242 + 1243 + The Nix flake also exposes a few `app` attributes (run `nix 1244 + flake show` to see a full list of what the flake provides), 1245 + one of the apps runs the appview with the `air` 1246 + live-reloader: 1247 + 1248 + ```bash 1249 + TANGLED_DEV=true nix run .#watch-appview 1250 + 1251 + # TANGLED_DB_PATH might be of interest to point to 1252 + # different sqlite DBs 1253 + 1254 + # in a separate shell, you can live-reload tailwind 1255 + nix run .#watch-tailwind 1256 + ``` 1257 + 1258 + To authenticate with the appview, you will need Redis and 1259 + OAuth JWKs to be set up: 1260 + 1261 + ``` 1262 + # OAuth JWKs should already be set up by the Nix devshell: 1263 + echo $TANGLED_OAUTH_CLIENT_SECRET 1264 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1265 + 1266 + echo $TANGLED_OAUTH_CLIENT_KID 1267 + 1761667908 1268 + 1269 + # if not, you can set it up yourself: 1270 + goat key generate -t P-256 1271 + Key Type: P-256 / secp256r1 / ES256 private key 1272 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 1273 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 1274 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 1275 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 1276 + 1277 + # the secret key from above 1278 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1279 + 1280 + # Run Redis in a new shell to store OAuth sessions 1281 + redis-server 1282 + ``` 1283 + 1284 + ## Running knots and spindles 1285 + 1286 + An end-to-end knot setup requires setting up a machine with 1287 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1288 + quite cumbersome. So the Nix flake provides a 1289 + `nixosConfiguration` to do so. 1290 + 1291 + <details> 1292 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1293 + 1294 + In order to build Tangled's dev VM on macOS, you will 1295 + first need to set up a Linux Nix builder. The recommended 1296 + way to do so is to run a [`darwin.linux-builder` 1297 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1298 + and to register it in `nix.conf` as a builder for Linux 1299 + with the same architecture as your Mac (`linux-aarch64` if 1300 + you are using Apple Silicon). 1301 + 1302 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1303 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1304 + > you can do 1305 + > 1306 + > ```shell 1307 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1308 + > ``` 1309 + > 1310 + > to store the builder VM in a temporary dir. 1311 + > 1312 + > You should read and follow [all the other intructions][darwin builder vm] to 1313 + > avoid subtle problems. 1314 + 1315 + Alternatively, you can use any other method to set up a 1316 + Linux machine with Nix installed that you can `sudo ssh` 1317 + into (in other words, root user on your Mac has to be able 1318 + to ssh into the Linux machine without entering a password) 1319 + and that has the same architecture as your Mac. See 1320 + [remote builder 1321 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1322 + for how to register such a builder in `nix.conf`. 1323 + 1324 + > WARNING: If you'd like to use 1325 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1326 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1327 + > ssh` works can be tricky. It seems to be [possible with 1328 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1329 + 1330 + </details> 1331 + 1332 + To begin, grab your DID from http://localhost:3000/settings. 1333 + Then, set `TANGLED_VM_KNOT_OWNER` and 1334 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 1335 + lightweight NixOS VM like so: 1336 + 1337 + ```bash 1338 + nix run --impure .#vm 1339 + 1340 + # type `poweroff` at the shell to exit the VM 1341 + ``` 1342 + 1343 + This starts a knot on port 6444, a spindle on port 6555 1344 + with `ssh` exposed on port 2222. 1345 + 1346 + Once the services are running, head to 1347 + http://localhost:3000/settings/knots and hit "Verify". It should 1348 + verify the ownership of the services instantly if everything 1349 + went smoothly. 1350 + 1351 + You can push repositories to this VM with this ssh config 1352 + block on your main machine: 1353 + 1354 + ```bash 1355 + Host nixos-shell 1356 + Hostname localhost 1357 + Port 2222 1358 + User git 1359 + IdentityFile ~/.ssh/my_tangled_key 1360 + ``` 1361 + 1362 + Set up a remote called `local-dev` on a git repo: 1363 + 1364 + ```bash 1365 + git remote add local-dev git@nixos-shell:user/repo 1366 + git push local-dev main 1367 + ``` 1368 + 1369 + The above VM should already be running a spindle on 1370 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1371 + hit "Verify". You can then configure each repository to use 1372 + this spindle and run CI jobs. 1373 + 1374 + Of interest when debugging spindles: 1375 + 1376 + ``` 1377 + # Service logs from journald: 1378 + journalctl -xeu spindle 1379 + 1380 + # CI job logs from disk: 1381 + ls /var/log/spindle 1382 + 1383 + # Debugging spindle database: 1384 + sqlite3 /var/lib/spindle/spindle.db 1385 + 1386 + # litecli has a nicer REPL interface: 1387 + litecli /var/lib/spindle/spindle.db 1388 + ``` 1389 + 1390 + If for any reason you wish to disable either one of the 1391 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 1392 + `services.tangled.spindle.enable` (or 1393 + `services.tangled.knot.enable`) to `false`. 1394 + 1395 + # Contribution guide 1396 + 1397 + ## Commit guidelines 1398 + 1399 + We follow a commit style similar to the Go project. Please keep commits: 1400 + 1401 + * **atomic**: each commit should represent one logical change 1402 + * **descriptive**: the commit message should clearly describe what the 1403 + change does and why it's needed 1404 + 1405 + ### Message format 1406 + 1407 + ``` 1408 + <service/top-level directory>/<affected package/directory>: <short summary of change> 1409 + 1410 + Optional longer description can go here, if necessary. Explain what the 1411 + change does and why, especially if not obvious. Reference relevant 1412 + issues or PRs when applicable. These can be links for now since we don't 1413 + auto-link issues/PRs yet. 1414 + ``` 1415 + 1416 + Here are some examples: 1417 + 1418 + ``` 1419 + appview/state: fix token expiry check in middleware 1420 + 1421 + The previous check did not account for clock drift, leading to premature 1422 + token invalidation. 1423 + ``` 1424 + 1425 + ``` 1426 + knotserver/git/service: improve error checking in upload-pack 1427 + ``` 1428 + 1429 + 1430 + ### General notes 1431 + 1432 + - PRs get merged "as-is" (fast-forward)โ€”like applying a patch-series 1433 + using `git am`. At present, there is no squashingโ€”so please author 1434 + your commits as they would appear on `master`, following the above 1435 + guidelines. 1436 + - If there is a lot of nesting, for example "appview: 1437 + pages/templates/repo/fragments: ...", these can be truncated down to 1438 + just "appview: repo/fragments: ...". If the change affects a lot of 1439 + subdirectories, you may abbreviate to just the top-level names, e.g. 1440 + "appview: ..." or "knotserver: ...". 1441 + - Keep commits lowercased with no trailing period. 1442 + - Use the imperative mood in the summary line (e.g., "fix bug" not 1443 + "fixed bug" or "fixes bug"). 1444 + - Try to keep the summary line under 72 characters, but we aren't too 1445 + fussed about this. 1446 + - Follow the same formatting for PR titles if filled manually. 1447 + - Don't include unrelated changes in the same commit. 1448 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 1449 + before submitting if necessary. 1450 + 1451 + ## Code formatting 1452 + 1453 + We use a variety of tools to format our code, and multiplex them with 1454 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1455 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1456 + 1457 + ## Proposals for bigger changes 1458 + 1459 + Small fixes like typos, minor bugs, or trivial refactors can be 1460 + submitted directly as PRs. 1461 + 1462 + For larger changesโ€”especially those introducing new features, significant 1463 + refactoring, or altering system behaviorโ€”please open a proposal first. This 1464 + helps us evaluate the scope, design, and potential impact before implementation. 1465 + 1466 + Create a new issue titled: 1467 + 1468 + ``` 1469 + proposal: <affected scope>: <summary of change> 1470 + ``` 1471 + 1472 + In the description, explain: 1473 + 1474 + - What the change is 1475 + - Why it's needed 1476 + - How you plan to implement it (roughly) 1477 + - Any open questions or tradeoffs 1478 + 1479 + We'll use the issue thread to discuss and refine the idea before moving 1480 + forward. 1481 + 1482 + ## Developer Certificate of Origin (DCO) 1483 + 1484 + We require all contributors to certify that they have the right to 1485 + submit the code they're contributing. To do this, we follow the 1486 + [Developer Certificate of Origin 1487 + (DCO)](https://developercertificate.org/). 1488 + 1489 + By signing your commits, you're stating that the contribution is your 1490 + own work, or that you have the right to submit it under the project's 1491 + license. This helps us keep things clean and legally sound. 1492 + 1493 + To sign your commit, just add the `-s` flag when committing: 1494 + 1495 + ```sh 1496 + git commit -s -m "your commit message" 1497 + ``` 1498 + 1499 + This appends a line like: 1500 + 1501 + ``` 1502 + Signed-off-by: Your Name <your.email@example.com> 1503 + ``` 1504 + 1505 + We won't merge commits if they aren't signed off. If you forget, you can 1506 + amend the last commit like this: 1507 + 1508 + ```sh 1509 + git commit --amend -s 1510 + ``` 1511 + 1512 + If you're submitting a PR with multiple commits, make sure each one is 1513 + signed. 1514 + 1515 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 1516 + to make it sign off commits in the tangled repo: 1517 + 1518 + ```shell 1519 + # Safety check, should say "No matching config key..." 1520 + jj config list templates.commit_trailers 1521 + # The command below may need to be adjusted if the command above returned something. 1522 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 1523 + ``` 1524 + 1525 + Refer to the [jujutsu 1526 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1527 + for more information.
-136
docs/contributing.md
··· 1 - # tangled contributing guide 2 - 3 - ## commit guidelines 4 - 5 - We follow a commit style similar to the Go project. Please keep commits: 6 - 7 - * **atomic**: each commit should represent one logical change 8 - * **descriptive**: the commit message should clearly describe what the 9 - change does and why it's needed 10 - 11 - ### message format 12 - 13 - ``` 14 - <service/top-level directory>/<affected package/directory>: <short summary of change> 15 - 16 - 17 - Optional longer description can go here, if necessary. Explain what the 18 - change does and why, especially if not obvious. Reference relevant 19 - issues or PRs when applicable. These can be links for now since we don't 20 - auto-link issues/PRs yet. 21 - ``` 22 - 23 - Here are some examples: 24 - 25 - ``` 26 - appview/state: fix token expiry check in middleware 27 - 28 - The previous check did not account for clock drift, leading to premature 29 - token invalidation. 30 - ``` 31 - 32 - ``` 33 - knotserver/git/service: improve error checking in upload-pack 34 - ``` 35 - 36 - 37 - ### general notes 38 - 39 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 40 - using `git am`. At present, there is no squashing -- so please author 41 - your commits as they would appear on `master`, following the above 42 - guidelines. 43 - - If there is a lot of nesting, for example "appview: 44 - pages/templates/repo/fragments: ...", these can be truncated down to 45 - just "appview: repo/fragments: ...". If the change affects a lot of 46 - subdirectories, you may abbreviate to just the top-level names, e.g. 47 - "appview: ..." or "knotserver: ...". 48 - - Keep commits lowercased with no trailing period. 49 - - Use the imperative mood in the summary line (e.g., "fix bug" not 50 - "fixed bug" or "fixes bug"). 51 - - Try to keep the summary line under 72 characters, but we aren't too 52 - fussed about this. 53 - - Follow the same formatting for PR titles if filled manually. 54 - - Don't include unrelated changes in the same commit. 55 - - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 - before submitting if necessary. 57 - 58 - ## code formatting 59 - 60 - We use a variety of tools to format our code, and multiplex them with 61 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 - is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 - 64 - ## proposals for bigger changes 65 - 66 - Small fixes like typos, minor bugs, or trivial refactors can be 67 - submitted directly as PRs. 68 - 69 - For larger changesโ€”especially those introducing new features, significant 70 - refactoring, or altering system behaviorโ€”please open a proposal first. This 71 - helps us evaluate the scope, design, and potential impact before implementation. 72 - 73 - ### proposal format 74 - 75 - Create a new issue titled: 76 - 77 - ``` 78 - proposal: <affected scope>: <summary of change> 79 - ``` 80 - 81 - In the description, explain: 82 - 83 - - What the change is 84 - - Why it's needed 85 - - How you plan to implement it (roughly) 86 - - Any open questions or tradeoffs 87 - 88 - We'll use the issue thread to discuss and refine the idea before moving 89 - forward. 90 - 91 - ## developer certificate of origin (DCO) 92 - 93 - We require all contributors to certify that they have the right to 94 - submit the code they're contributing. To do this, we follow the 95 - [Developer Certificate of Origin 96 - (DCO)](https://developercertificate.org/). 97 - 98 - By signing your commits, you're stating that the contribution is your 99 - own work, or that you have the right to submit it under the project's 100 - license. This helps us keep things clean and legally sound. 101 - 102 - To sign your commit, just add the `-s` flag when committing: 103 - 104 - ```sh 105 - git commit -s -m "your commit message" 106 - ``` 107 - 108 - This appends a line like: 109 - 110 - ``` 111 - Signed-off-by: Your Name <your.email@example.com> 112 - ``` 113 - 114 - We won't merge commits if they aren't signed off. If you forget, you can 115 - amend the last commit like this: 116 - 117 - ```sh 118 - git commit --amend -s 119 - ``` 120 - 121 - If you're submitting a PR with multiple commits, make sure each one is 122 - signed. 123 - 124 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 - to make it sign off commits in the tangled repo: 126 - 127 - ```shell 128 - # Safety check, should say "No matching config key..." 129 - jj config list templates.commit_trailers 130 - # The command below may need to be adjusted if the command above returned something. 131 - jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 132 - ``` 133 - 134 - Refer to the [jj 135 - documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 - for more information.
-172
docs/hacking.md
··· 1 - # hacking on tangled 2 - 3 - We highly recommend [installing 4 - nix](https://nixos.org/download/) (the package manager) 5 - before working on the codebase. The nix flake provides a lot 6 - of helpers to get started and most importantly, builds and 7 - dev shells are entirely deterministic. 8 - 9 - To set up your dev environment: 10 - 11 - ```bash 12 - nix develop 13 - ``` 14 - 15 - Non-nix users can look at the `devShell` attribute in the 16 - `flake.nix` file to determine necessary dependencies. 17 - 18 - ## running the appview 19 - 20 - The nix flake also exposes a few `app` attributes (run `nix 21 - flake show` to see a full list of what the flake provides), 22 - one of the apps runs the appview with the `air` 23 - live-reloader: 24 - 25 - ```bash 26 - TANGLED_DEV=true nix run .#watch-appview 27 - 28 - # TANGLED_DB_PATH might be of interest to point to 29 - # different sqlite DBs 30 - 31 - # in a separate shell, you can live-reload tailwind 32 - nix run .#watch-tailwind 33 - ``` 34 - 35 - To authenticate with the appview, you will need redis and 36 - OAUTH JWKs to be setup: 37 - 38 - ``` 39 - # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 45 - 46 - # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 - 57 - # run redis in at a new shell to store oauth sessions 58 - redis-server 59 - ``` 60 - 61 - ## running knots and spindles 62 - 63 - An end-to-end knot setup requires setting up a machine with 64 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 65 - quite cumbersome. So the nix flake provides a 66 - `nixosConfiguration` to do so. 67 - 68 - <details> 69 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 70 - 71 - In order to build Tangled's dev VM on macOS, you will 72 - first need to set up a Linux Nix builder. The recommended 73 - way to do so is to run a [`darwin.linux-builder` 74 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 75 - and to register it in `nix.conf` as a builder for Linux 76 - with the same architecture as your Mac (`linux-aarch64` if 77 - you are using Apple Silicon). 78 - 79 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 80 - > the tangled repo so that it doesn't conflict with the other VM. For example, 81 - > you can do 82 - > 83 - > ```shell 84 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 85 - > ``` 86 - > 87 - > to store the builder VM in a temporary dir. 88 - > 89 - > You should read and follow [all the other intructions][darwin builder vm] to 90 - > avoid subtle problems. 91 - 92 - Alternatively, you can use any other method to set up a 93 - Linux machine with `nix` installed that you can `sudo ssh` 94 - into (in other words, root user on your Mac has to be able 95 - to ssh into the Linux machine without entering a password) 96 - and that has the same architecture as your Mac. See 97 - [remote builder 98 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 99 - for how to register such a builder in `nix.conf`. 100 - 101 - > WARNING: If you'd like to use 102 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 103 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 104 - > ssh` works can be tricky. It seems to be [possible with 105 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 106 - 107 - </details> 108 - 109 - To begin, grab your DID from http://localhost:3000/settings. 110 - Then, set `TANGLED_VM_KNOT_OWNER` and 111 - `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 112 - lightweight NixOS VM like so: 113 - 114 - ```bash 115 - nix run --impure .#vm 116 - 117 - # type `poweroff` at the shell to exit the VM 118 - ``` 119 - 120 - This starts a knot on port 6444, a spindle on port 6555 121 - with `ssh` exposed on port 2222. 122 - 123 - Once the services are running, head to 124 - http://localhost:3000/settings/knots and hit verify. It should 125 - verify the ownership of the services instantly if everything 126 - went smoothly. 127 - 128 - You can push repositories to this VM with this ssh config 129 - block on your main machine: 130 - 131 - ```bash 132 - Host nixos-shell 133 - Hostname localhost 134 - Port 2222 135 - User git 136 - IdentityFile ~/.ssh/my_tangled_key 137 - ``` 138 - 139 - Set up a remote called `local-dev` on a git repo: 140 - 141 - ```bash 142 - git remote add local-dev git@nixos-shell:user/repo 143 - git push local-dev main 144 - ``` 145 - 146 - ### running a spindle 147 - 148 - The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/settings/spindles and 150 - hit verify. You can then configure each repository to use 151 - this spindle and run CI jobs. 152 - 153 - Of interest when debugging spindles: 154 - 155 - ``` 156 - # service logs from journald: 157 - journalctl -xeu spindle 158 - 159 - # CI job logs from disk: 160 - ls /var/log/spindle 161 - 162 - # debugging spindle db: 163 - sqlite3 /var/lib/spindle/spindle.db 164 - 165 - # litecli has a nicer REPL interface: 166 - litecli /var/lib/spindle/spindle.db 167 - ``` 168 - 169 - If for any reason you wish to disable either one of the 170 - services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled.spindle.enable` (or 172 - `services.tangled.knot.enable`) to `false`.
+93
docs/highlight.theme
··· 1 + { 2 + "text-color": null, 3 + "background-color": null, 4 + "line-number-color": null, 5 + "line-number-background-color": null, 6 + "text-styles": { 7 + "Annotation": { 8 + "text-color": null, 9 + "background-color": null, 10 + "bold": false, 11 + "italic": true, 12 + "underline": false 13 + }, 14 + "ControlFlow": { 15 + "text-color": null, 16 + "background-color": null, 17 + "bold": true, 18 + "italic": false, 19 + "underline": false 20 + }, 21 + "Error": { 22 + "text-color": null, 23 + "background-color": null, 24 + "bold": true, 25 + "italic": false, 26 + "underline": false 27 + }, 28 + "Alert": { 29 + "text-color": null, 30 + "background-color": null, 31 + "bold": true, 32 + "italic": false, 33 + "underline": false 34 + }, 35 + "Preprocessor": { 36 + "text-color": null, 37 + "background-color": null, 38 + "bold": true, 39 + "italic": false, 40 + "underline": false 41 + }, 42 + "Information": { 43 + "text-color": null, 44 + "background-color": null, 45 + "bold": false, 46 + "italic": true, 47 + "underline": false 48 + }, 49 + "Warning": { 50 + "text-color": null, 51 + "background-color": null, 52 + "bold": false, 53 + "italic": true, 54 + "underline": false 55 + }, 56 + "Documentation": { 57 + "text-color": null, 58 + "background-color": null, 59 + "bold": false, 60 + "italic": true, 61 + "underline": false 62 + }, 63 + "DataType": { 64 + "text-color": "#8f4e8b", 65 + "background-color": null, 66 + "bold": false, 67 + "italic": false, 68 + "underline": false 69 + }, 70 + "Comment": { 71 + "text-color": null, 72 + "background-color": null, 73 + "bold": false, 74 + "italic": true, 75 + "underline": false 76 + }, 77 + "CommentVar": { 78 + "text-color": null, 79 + "background-color": null, 80 + "bold": false, 81 + "italic": true, 82 + "underline": false 83 + }, 84 + "Keyword": { 85 + "text-color": null, 86 + "background-color": null, 87 + "bold": true, 88 + "italic": false, 89 + "underline": false 90 + } 91 + } 92 + } 93 +
-214
docs/knot-hosting.md
··· 1 - # knot self-hosting guide 2 - 3 - So you want to run your own knot server? Great! Here are a few prerequisites: 4 - 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 - 2. A (sub)domain name. People generally use `knot.example.com`. 7 - 3. A valid SSL certificate for your domain. 8 - 9 - There's a couple of ways to get started: 10 - * NixOS: refer to 11 - [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 12 - * Docker: Documented at 13 - [@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker) 14 - (community maintained: support is not guaranteed!) 15 - * Manual: Documented below. 16 - 17 - ## manual setup 18 - 19 - First, clone this repository: 20 - 21 - ``` 22 - git clone https://tangled.org/@tangled.org/core 23 - ``` 24 - 25 - Then, build the `knot` CLI. This is the knot administration and operation tool. 26 - For the purpose of this guide, we're only concerned with these subcommands: 27 - 28 - * `knot server`: the main knot server process, typically run as a 29 - supervised service 30 - * `knot guard`: handles role-based access control for git over SSH 31 - (you'll never have to run this yourself) 32 - * `knot keys`: fetches SSH keys associated with your knot; we'll use 33 - this to generate the SSH `AuthorizedKeysCommand` 34 - 35 - ``` 36 - cd core 37 - export CGO_ENABLED=1 38 - go build -o knot ./cmd/knot 39 - ``` 40 - 41 - Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 - 44 - ``` 45 - sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 - ``` 48 - 49 - This is necessary because SSH `AuthorizedKeysCommand` requires [really 50 - specific permissions](https://stackoverflow.com/a/27638306). The 51 - `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 52 - retrieve a user's public SSH keys dynamically for authentication. Let's 53 - set that up. 54 - 55 - ``` 56 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 57 - Match User git 58 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 59 - AuthorizedKeysCommandUser nobody 60 - EOF 61 - ``` 62 - 63 - Then, reload `sshd`: 64 - 65 - ``` 66 - sudo systemctl reload ssh 67 - ``` 68 - 69 - Next, create the `git` user. We'll use the `git` user's home directory 70 - to store repositories: 71 - 72 - ``` 73 - sudo adduser git 74 - ``` 75 - 76 - Create `/home/git/.knot.env` with the following, updating the values as 77 - necessary. The `KNOT_SERVER_OWNER` should be set to your 78 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_OWNER=did:plc:foobar 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - The last step is to configure a reverse proxy like Nginx or Caddy to front your 100 - knot. Here's an example configuration for Nginx: 101 - 102 - ``` 103 - server { 104 - listen 80; 105 - listen [::]:80; 106 - server_name knot.example.com; 107 - 108 - location / { 109 - proxy_pass http://localhost:5555; 110 - proxy_set_header Host $host; 111 - proxy_set_header X-Real-IP $remote_addr; 112 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 - proxy_set_header X-Forwarded-Proto $scheme; 114 - } 115 - 116 - # wss endpoint for git events 117 - location /events { 118 - proxy_set_header X-Forwarded-For $remote_addr; 119 - proxy_set_header Host $http_host; 120 - proxy_set_header Upgrade websocket; 121 - proxy_set_header Connection Upgrade; 122 - proxy_pass http://localhost:5555; 123 - } 124 - # additional config for SSL/TLS go here. 125 - } 126 - 127 - ``` 128 - 129 - Remember to use Let's Encrypt or similar to procure a certificate for your 130 - knot domain. 131 - 132 - You should now have a running knot server! You can finalize 133 - your registration by hitting the `verify` button on the 134 - [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 135 - a record on your PDS to announce the existence of the knot. 136 - 137 - ### custom paths 138 - 139 - (This section applies to manual setup only. Docker users should edit the mounts 140 - in `docker-compose.yml` instead.) 141 - 142 - Right now, the database and repositories of your knot lives in `/home/git`. You 143 - can move these paths if you'd like to store them in another folder. Be careful 144 - when adjusting these paths: 145 - 146 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 147 - any possible side effects. Remember to restart it once you're done. 148 - * Make backups before moving in case something goes wrong. 149 - * Make sure the `git` user can read and write from the new paths. 150 - 151 - #### database 152 - 153 - As an example, let's say the current database is at `/home/git/knotserver.db`, 154 - and we want to move it to `/home/git/database/knotserver.db`. 155 - 156 - Copy the current database to the new location. Make sure to copy the `.db-shm` 157 - and `.db-wal` files if they exist. 158 - 159 - ``` 160 - mkdir /home/git/database 161 - cp /home/git/knotserver.db* /home/git/database 162 - ``` 163 - 164 - In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 165 - the new file path (_not_ the directory): 166 - 167 - ``` 168 - KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 169 - ``` 170 - 171 - #### repositories 172 - 173 - As an example, let's say the repositories are currently in `/home/git`, and we 174 - want to move them into `/home/git/repositories`. 175 - 176 - Create the new folder, then move the existing repositories (if there are any): 177 - 178 - ``` 179 - mkdir /home/git/repositories 180 - # move all DIDs into the new folder; these will vary for you! 181 - mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 182 - ``` 183 - 184 - In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 185 - to the new directory: 186 - 187 - ``` 188 - KNOT_REPO_SCAN_PATH=/home/git/repositories 189 - ``` 190 - 191 - Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 192 - repository path: 193 - 194 - ``` 195 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 196 - Match User git 197 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 198 - AuthorizedKeysCommandUser nobody 199 - EOF 200 - ``` 201 - 202 - Make sure to restart your SSH server! 203 - 204 - #### MOTD (message of the day) 205 - 206 - To configure the MOTD used ("Welcome to this knot!" by default), edit the 207 - `/home/git/motd` file: 208 - 209 - ``` 210 - printf "Hi from this knot!\n" > /home/git/motd 211 - ``` 212 - 213 - Note that you should add a newline at the end if setting a non-empty message 214 - since the knot won't do this for you.
-59
docs/migrations.md
··· 1 - # Migrations 2 - 3 - This document is laid out in reverse-chronological order. 4 - Newer migration guides are listed first, and older guides 5 - are further down the page. 6 - 7 - ## Upgrading from v1.8.x 8 - 9 - After v1.8.2, the HTTP API for knot and spindles have been 10 - deprecated and replaced with XRPC. Repositories on outdated 11 - knots will not be viewable from the appview. Upgrading is 12 - straightforward however. 13 - 14 - For knots: 15 - 16 - - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 - hit the "retry" button to verify your knot 19 - 20 - For spindles: 21 - 22 - - Upgrade to latest tag (v1.9.0 or above) 23 - - Head to the [spindle 24 - dashboard](https://tangled.org/settings/spindles) and hit the 25 - "retry" button to verify your spindle 26 - 27 - ## Upgrading from v1.7.x 28 - 29 - After v1.7.0, knot secrets have been deprecated. You no 30 - longer need a secret from the appview to run a knot. All 31 - authorized commands to knots are managed via [Inter-Service 32 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 - Knots will be read-only until upgraded. 34 - 35 - Upgrading is quite easy, in essence: 36 - 37 - - `KNOT_SERVER_SECRET` is no more, you can remove this 38 - environment variable entirely 39 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 - your DID. You can find your DID in the 41 - [settings](https://tangled.org/settings) page. 42 - - Restart your knot once you have replaced the environment 43 - variable 44 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 - hit the "retry" button to verify your knot. This simply 46 - writes a `sh.tangled.knot` record to your PDS. 47 - 48 - If you use the nix module, simply bump the flake to the 49 - latest revision, and change your config block like so: 50 - 51 - ```diff 52 - services.tangled.knot = { 53 - enable = true; 54 - server = { 55 - - secretFile = /path/to/secret; 56 - + owner = "did:plc:foo"; 57 - }; 58 - }; 59 - ```
+3
docs/mode.html
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
-25
docs/spindle/architecture.md
··· 1 - # spindle architecture 2 - 3 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 - 5 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 - * when a new repo record comes through (typically when you add a spindle to a 8 - repo from the settings), spindle then resolves the underlying knot and 9 - subscribes to repo events (see: 10 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 - * the spindle engine then handles execution of the pipeline, with results and 12 - logs beamed on the spindle event stream over wss 13 - 14 - ### the engine 15 - 16 - At present, the only supported backend is Docker (and Podman, if Docker 17 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 - executes each step in the pipeline in a fresh container, with state persisted 19 - across steps within the `/tangled/workspace` directory. 20 - 21 - The base image for the container is constructed on the fly using 22 - [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 - used packages. 24 - 25 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
··· 1 - # spindle self-hosting guide 2 - 3 - ## prerequisites 4 - 5 - * Go 6 - * Docker (the only supported backend currently) 7 - 8 - ## configuration 9 - 10 - Spindle is configured using environment variables. The following environment variables are available: 11 - 12 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 - 22 - ## running spindle 23 - 24 - 1. **Set the environment variables.** For example: 25 - 26 - ```shell 27 - export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 - export SPINDLE_SERVER_OWNER="your-did" 29 - ``` 30 - 31 - 2. **Build the Spindle binary.** 32 - 33 - ```shell 34 - cd core 35 - go mod download 36 - go build -o cmd/spindle/spindle cmd/spindle/main.go 37 - ``` 38 - 39 - 3. **Create the log directory.** 40 - 41 - ```shell 42 - sudo mkdir -p /var/log/spindle 43 - sudo chown $USER:$USER -R /var/log/spindle 44 - ``` 45 - 46 - 4. **Run the Spindle binary.** 47 - 48 - ```shell 49 - ./cmd/spindle/spindle 50 - ``` 51 - 52 - Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
··· 1 - # spindle secrets with openbao 2 - 3 - This document covers setting up Spindle to use OpenBao for secrets 4 - management via OpenBao Proxy instead of the default SQLite backend. 5 - 6 - ## overview 7 - 8 - Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 - authentication automatically using AppRole credentials, while Spindle 10 - connects to the local proxy instead of directly to the OpenBao server. 11 - 12 - This approach provides better security, automatic token renewal, and 13 - simplified application code. 14 - 15 - ## installation 16 - 17 - Install OpenBao from nixpkgs: 18 - 19 - ```bash 20 - nix shell nixpkgs#openbao # for a local server 21 - ``` 22 - 23 - ## setup 24 - 25 - The setup process can is documented for both local development and production. 26 - 27 - ### local development 28 - 29 - Start OpenBao in dev mode: 30 - 31 - ```bash 32 - bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 - ``` 34 - 35 - This starts OpenBao on `http://localhost:8201` with a root token. 36 - 37 - Set up environment for bao CLI: 38 - 39 - ```bash 40 - export BAO_ADDR=http://localhost:8200 41 - export BAO_TOKEN=root 42 - ``` 43 - 44 - ### production 45 - 46 - You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 - achieved using Nix. 49 - 50 - Then, initialize the bao server: 51 - ```bash 52 - bao operator init -key-shares=1 -key-threshold=1 53 - ``` 54 - 55 - This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 - ```bash 57 - bao operator unseal <unseal_key> 58 - ``` 59 - 60 - All steps below remain the same across both dev and production setups. 61 - 62 - ### configure openbao server 63 - 64 - Create the spindle KV mount: 65 - 66 - ```bash 67 - bao secrets enable -path=spindle -version=2 kv 68 - ``` 69 - 70 - Set up AppRole authentication and policy: 71 - 72 - Create a policy file `spindle-policy.hcl`: 73 - 74 - ```hcl 75 - # Full access to spindle KV v2 data 76 - path "spindle/data/*" { 77 - capabilities = ["create", "read", "update", "delete"] 78 - } 79 - 80 - # Access to metadata for listing and management 81 - path "spindle/metadata/*" { 82 - capabilities = ["list", "read", "delete", "update"] 83 - } 84 - 85 - # Allow listing at root level 86 - path "spindle/" { 87 - capabilities = ["list"] 88 - } 89 - 90 - # Required for connection testing and health checks 91 - path "auth/token/lookup-self" { 92 - capabilities = ["read"] 93 - } 94 - ``` 95 - 96 - Apply the policy and create an AppRole: 97 - 98 - ```bash 99 - bao policy write spindle-policy spindle-policy.hcl 100 - bao auth enable approle 101 - bao write auth/approle/role/spindle \ 102 - token_policies="spindle-policy" \ 103 - token_ttl=1h \ 104 - token_max_ttl=4h \ 105 - bind_secret_id=true \ 106 - secret_id_ttl=0 \ 107 - secret_id_num_uses=0 108 - ``` 109 - 110 - Get the credentials: 111 - 112 - ```bash 113 - # Get role ID (static) 114 - ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 - 116 - # Generate secret ID 117 - SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 - 119 - echo "Role ID: $ROLE_ID" 120 - echo "Secret ID: $SECRET_ID" 121 - ``` 122 - 123 - ### create proxy configuration 124 - 125 - Create the credential files: 126 - 127 - ```bash 128 - # Create directory for OpenBao files 129 - mkdir -p /tmp/openbao 130 - 131 - # Save credentials 132 - echo "$ROLE_ID" > /tmp/openbao/role-id 133 - echo "$SECRET_ID" > /tmp/openbao/secret-id 134 - chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 - ``` 136 - 137 - Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 - 139 - ```hcl 140 - # OpenBao server connection 141 - vault { 142 - address = "http://localhost:8200" 143 - } 144 - 145 - # Auto-Auth using AppRole 146 - auto_auth { 147 - method "approle" { 148 - mount_path = "auth/approle" 149 - config = { 150 - role_id_file_path = "/tmp/openbao/role-id" 151 - secret_id_file_path = "/tmp/openbao/secret-id" 152 - } 153 - } 154 - 155 - # Optional: write token to file for debugging 156 - sink "file" { 157 - config = { 158 - path = "/tmp/openbao/token" 159 - mode = 0640 160 - } 161 - } 162 - } 163 - 164 - # Proxy listener for Spindle 165 - listener "tcp" { 166 - address = "127.0.0.1:8201" 167 - tls_disable = true 168 - } 169 - 170 - # Enable API proxy with auto-auth token 171 - api_proxy { 172 - use_auto_auth_token = true 173 - } 174 - 175 - # Enable response caching 176 - cache { 177 - use_auto_auth_token = true 178 - } 179 - 180 - # Logging 181 - log_level = "info" 182 - ``` 183 - 184 - ### start the proxy 185 - 186 - Start OpenBao Proxy: 187 - 188 - ```bash 189 - bao proxy -config=/tmp/openbao/proxy.hcl 190 - ``` 191 - 192 - The proxy will authenticate with OpenBao and start listening on 193 - `127.0.0.1:8201`. 194 - 195 - ### configure spindle 196 - 197 - Set these environment variables for Spindle: 198 - 199 - ```bash 200 - export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 - export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 - export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 - ``` 204 - 205 - Start Spindle: 206 - 207 - Spindle will now connect to the local proxy, which handles all 208 - authentication automatically. 209 - 210 - ## production setup for proxy 211 - 212 - For production, you'll want to run the proxy as a service: 213 - 214 - Place your production configuration in `/etc/openbao/proxy.hcl` with 215 - proper TLS settings for the vault connection. 216 - 217 - ## verifying setup 218 - 219 - Test the proxy directly: 220 - 221 - ```bash 222 - # Check proxy health 223 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 - 225 - # Test token lookup through proxy 226 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 - ``` 228 - 229 - Test OpenBao operations through the server: 230 - 231 - ```bash 232 - # List all secrets 233 - bao kv list spindle/ 234 - 235 - # Add a test secret via Spindle API, then check it exists 236 - bao kv list spindle/repos/ 237 - 238 - # Get a specific secret 239 - bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 - ``` 241 - 242 - ## how it works 243 - 244 - - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 - - The proxy authenticates with OpenBao using AppRole credentials 246 - - All Spindle requests go through the proxy, which injects authentication tokens 247 - - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 - - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 - - The proxy handles all token renewal automatically 250 - - Spindle no longer manages tokens or authentication directly 251 - 252 - ## troubleshooting 253 - 254 - **Connection refused**: Check that the OpenBao Proxy is running and 255 - listening on the configured address. 256 - 257 - **403 errors**: Verify the AppRole credentials are correct and the policy 258 - has the necessary permissions. 259 - 260 - **404 route errors**: The spindle KV mount probably doesn't exist - run 261 - the mount creation step again. 262 - 263 - **Proxy authentication failures**: Check the proxy logs and verify the 264 - role-id and secret-id files are readable and contain valid credentials. 265 - 266 - **Secret not found after writing**: This can indicate policy permission 267 - issues. Verify the policy includes both `spindle/data/*` and 268 - `spindle/metadata/*` paths with appropriate capabilities. 269 - 270 - Check proxy logs: 271 - 272 - ```bash 273 - # If running as systemd service 274 - journalctl -u openbao-proxy -f 275 - 276 - # If running directly, check the console output 277 - ``` 278 - 279 - Test AppRole authentication manually: 280 - 281 - ```bash 282 - bao write auth/approle/login \ 283 - role_id="$(cat /tmp/openbao/role-id)" \ 284 - secret_id="$(cat /tmp/openbao/secret-id)" 285 - ```
-183
docs/spindle/pipeline.md
··· 1 - # spindle pipelines 2 - 3 - Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 - 5 - The fields are: 6 - 7 - - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 - - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 - - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 - - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 - - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 - - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 - 14 - ## Trigger 15 - 16 - The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 - 18 - - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - - `push`: The workflow should run every time a commit is pushed to the repository. 20 - - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - - `manual`: The workflow can be triggered manually. 22 - - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 - 25 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 - 27 - ```yaml 28 - when: 29 - - event: ["push", "manual"] 30 - branch: ["main", "develop"] 31 - - event: ["pull_request"] 32 - branch: ["main"] 33 - ``` 34 - 35 - You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 - 37 - ```yaml 38 - when: 39 - - event: ["push"] 40 - tag: ["v*"] 41 - ``` 42 - 43 - You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 - 45 - ```yaml 46 - when: 47 - - event: ["push"] 48 - branch: ["main", "release-*"] 49 - tag: ["v*", "stable"] 50 - ``` 51 - 52 - ## Engine 53 - 54 - Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 55 - 56 - - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 57 - 58 - Example: 59 - 60 - ```yaml 61 - engine: "nixery" 62 - ``` 63 - 64 - ## Clone options 65 - 66 - When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 67 - 68 - - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 69 - - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 70 - - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 71 - 72 - The default settings are: 73 - 74 - ```yaml 75 - clone: 76 - skip: false 77 - depth: 1 78 - submodules: false 79 - ``` 80 - 81 - ## Dependencies 82 - 83 - Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 84 - 85 - Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 86 - 87 - ```yaml 88 - dependencies: 89 - # nixpkgs 90 - nixpkgs: 91 - - nodejs 92 - - go 93 - # custom registry 94 - git+https://tangled.org/@example.com/my_pkg: 95 - - my_pkg 96 - ``` 97 - 98 - Now these dependencies are available to use in your workflow! 99 - 100 - ## Environment 101 - 102 - The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 - 104 - Example: 105 - 106 - ```yaml 107 - environment: 108 - GOOS: "linux" 109 - GOARCH: "arm64" 110 - NODE_ENV: "production" 111 - MY_ENV_VAR: "MY_ENV_VALUE" 112 - ``` 113 - 114 - ## Steps 115 - 116 - The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 117 - 118 - - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 119 - - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 120 - - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 121 - 122 - Example: 123 - 124 - ```yaml 125 - steps: 126 - - name: "Build backend" 127 - command: "go build" 128 - environment: 129 - GOOS: "darwin" 130 - GOARCH: "arm64" 131 - - name: "Build frontend" 132 - command: "npm run build" 133 - environment: 134 - NODE_ENV: "production" 135 - ``` 136 - 137 - ## Complete workflow 138 - 139 - ```yaml 140 - # .tangled/workflows/build.yml 141 - 142 - when: 143 - - event: ["push", "manual"] 144 - branch: ["main", "develop"] 145 - - event: ["pull_request"] 146 - branch: ["main"] 147 - 148 - engine: "nixery" 149 - 150 - # using the default values 151 - clone: 152 - skip: false 153 - depth: 1 154 - submodules: false 155 - 156 - dependencies: 157 - # nixpkgs 158 - nixpkgs: 159 - - nodejs 160 - - go 161 - # custom registry 162 - git+https://tangled.org/@example.com/my_pkg: 163 - - my_pkg 164 - 165 - environment: 166 - GOOS: "linux" 167 - GOARCH: "arm64" 168 - NODE_ENV: "production" 169 - MY_ENV_VAR: "MY_ENV_VALUE" 170 - 171 - steps: 172 - - name: "Build backend" 173 - command: "go build" 174 - environment: 175 - GOOS: "darwin" 176 - GOARCH: "arm64" 177 - - name: "Build frontend" 178 - command: "npm run build" 179 - environment: 180 - NODE_ENV: "production" 181 - ``` 182 - 183 - If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
··· 1 + svg { 2 + width: 16px; 3 + height: 16px; 4 + } 5 + 6 + :root { 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 36 + } 37 + 38 + @media (prefers-color-scheme: dark) { 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 70 + } 71 + 72 + /* pandoc syntax highlighting classes */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+156
docs/template.html
··· 1 + <!DOCTYPE html> 2 + <html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="generator" content="pandoc" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 7 + $for(author-meta)$ 8 + <meta name="author" content="$author-meta$" /> 9 + $endfor$ 10 + 11 + $if(date-meta)$ 12 + <meta name="dcterms.date" content="$date-meta$" /> 13 + $endif$ 14 + 15 + $if(keywords)$ 16 + <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" /> 17 + $endif$ 18 + 19 + $if(description-meta)$ 20 + <meta name="description" content="$description-meta$" /> 21 + $endif$ 22 + 23 + <title>$pagetitle$</title> 24 + 25 + <style> 26 + $styles.css()$ 27 + </style> 28 + 29 + $for(css)$ 30 + <link rel="stylesheet" href="$css$" /> 31 + $endfor$ 32 + 33 + $for(header-includes)$ 34 + $header-includes$ 35 + $endfor$ 36 + 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + 39 + </head> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 + $for(include-before)$ 42 + $include-before$ 43 + $endfor$ 44 + 45 + $if(toc)$ 46 + <!-- mobile TOC trigger --> 47 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 48 + <button 49 + type="button" 50 + popovertarget="mobile-toc-popover" 51 + popovertargetaction="toggle" 52 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 53 + > 54 + ${ menu.svg() } 55 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="mobile-toc-popover" 61 + popover 62 + class="mobile-toc-popover 63 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 64 + h-full overflow-y-auto shadow-sm 65 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 66 + > 67 + <div class="flex flex-col min-h-full"> 68 + <div class="flex-1 space-y-4"> 69 + <button 70 + type="button" 71 + popovertarget="mobile-toc-popover" 72 + popovertargetaction="toggle" 73 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 74 + ${ x.svg() } 75 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 + </button> 77 + ${ search.html() } 78 + ${ table-of-contents:toc.html() } 79 + </div> 80 + ${ single-page:mode.html() } 81 + </div> 82 + </div> 83 + 84 + <!-- desktop sidebar toc --> 85 + <nav 86 + id="$idprefix$TOC" 87 + role="doc-toc" 88 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 89 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 90 + p-4 z-50 overflow-y-auto"> 91 + ${ search.html() } 92 + <div class="flex-1"> 93 + $if(toc-title)$ 94 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 95 + $endif$ 96 + ${ table-of-contents:toc.html() } 97 + </div> 98 + ${ single-page:mode.html() } 99 + </nav> 100 + $endif$ 101 + 102 + <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 103 + <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 104 + $if(top)$ 105 + $-- only print title block if this is NOT the top page 106 + $else$ 107 + $if(title)$ 108 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 109 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 110 + $if(subtitle)$ 111 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 112 + $endif$ 113 + $for(author)$ 114 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 115 + $endfor$ 116 + $if(date)$ 117 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 118 + $endif$ 119 + $endif$ 120 + </header> 121 + $endif$ 122 + 123 + $if(abstract)$ 124 + <article class="prose dark:prose-invert max-w-none"> 125 + $abstract$ 126 + </article> 127 + $endif$ 128 + 129 + <article class="prose dark:prose-invert max-w-none"> 130 + $body$ 131 + </article> 132 + </main> 133 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 134 + <div class="max-w-4xl mx-auto px-8 py-4"> 135 + <div class="flex justify-between gap-4"> 136 + <span class="flex-1"> 137 + $if(previous.url)$ 138 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span> 139 + <a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a> 140 + $endif$ 141 + </span> 142 + <span class="flex-1 text-right"> 143 + $if(next.url)$ 144 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span> 145 + <a href="$next.url$" accesskey="n" rel="next">$next.title$</a> 146 + $endif$ 147 + </span> 148 + </div> 149 + </div> 150 + </nav> 151 + </div> 152 + $for(include-after)$ 153 + $include-after$ 154 + $endfor$ 155 + </body> 156 + </html>
+4
docs/toc.html
··· 1 + <div class="[&_ul]:space-y-6 [&_ul]:pl-0 [&_ul]:font-bold [&_ul_ul]:pl-4 [&_ul_ul]:font-normal [&_ul_ul]:space-y-2 [&_li]:space-y-2"> 2 + $table-of-contents$ 3 + </div> 4 +
+3 -3
flake.lock
··· 150 150 }, 151 151 "nixpkgs": { 152 152 "locked": { 153 - "lastModified": 1765186076, 154 - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 155 "owner": "nixos", 156 156 "repo": "nixpkgs", 157 - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 158 "type": "github" 159 159 }, 160 160 "original": {
+6 -3
flake.nix
··· 76 76 }; 77 77 buildGoApplication = 78 78 (self.callPackage "${gomod2nix}/builder" { 79 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 80 80 }).buildGoApplication; 81 81 modules = ./nix/gomod2nix.toml; 82 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { ··· 88 88 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 89 89 }; 90 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 + docs = self.callPackage ./nix/pkgs/docs.nix { 92 + inherit inter-fonts-src ibm-plex-mono-src lucide-src; 93 + }; 91 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 92 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 93 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 94 97 }); 95 98 in { 96 99 overlays.default = final: prev: { 97 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 100 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs; 98 101 }; 99 102 100 103 packages = forAllSystems (system: let ··· 103 106 staticPackages = mkPackageSet pkgs.pkgsStatic; 104 107 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 105 108 in { 106 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 109 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs; 107 110 108 111 pkgsStatic-appview = staticPackages.appview; 109 112 pkgsStatic-knot = staticPackages.knot;
+2 -1
input.css
··· 162 162 } 163 163 164 164 .prose a.mention { 165 - @apply no-underline hover:underline; 165 + @apply no-underline hover:underline font-bold; 166 166 } 167 167 168 168 .prose li { ··· 255 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 256 } 257 257 } 258 + 258 259 } 259 260 260 261 /* Background */
+10 -2
lexicons/pulls/pull.json
··· 12 12 "required": [ 13 13 "target", 14 14 "title", 15 - "patch", 15 + "patchBlob", 16 16 "createdAt" 17 17 ], 18 18 "properties": { ··· 27 27 "type": "string" 28 28 }, 29 29 "patch": { 30 - "type": "string" 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 31 39 }, 32 40 "source": { 33 41 "type": "ref",
+53
nix/pkgs/docs.nix
··· 1 + { 2 + pandoc, 3 + tailwindcss, 4 + runCommandLocal, 5 + inter-fonts-src, 6 + ibm-plex-mono-src, 7 + lucide-src, 8 + src, 9 + }: 10 + runCommandLocal "docs" {} '' 11 + mkdir -p working 12 + 13 + # copy templates, themes, styles, filters to working directory 14 + cp ${src}/docs/*.html working/ 15 + cp ${src}/docs/*.theme working/ 16 + cp ${src}/docs/*.css working/ 17 + 18 + # icons 19 + cp -rf ${lucide-src}/*.svg working/ 20 + 21 + # content - chunked 22 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 + -o $out/ \ 24 + -t chunkedhtml \ 25 + --variable toc \ 26 + --variable-json single-page=false \ 27 + --toc-depth=2 \ 28 + --css=stylesheet.css \ 29 + --chunk-template="%i.html" \ 30 + --highlight-style=working/highlight.theme \ 31 + --template=working/template.html 32 + 33 + # content - single page 34 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 35 + -o $out/single-page.html \ 36 + --toc \ 37 + --variable toc \ 38 + --variable single-page \ 39 + --toc-depth=2 \ 40 + --css=stylesheet.css \ 41 + --highlight-style=working/highlight.theme \ 42 + --template=working/template.html 43 + 44 + # fonts 45 + mkdir -p $out/static/fonts 46 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 47 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 48 + cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 49 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 50 + 51 + # styles 52 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 53 + ''
+1 -1
nix/vm.nix
··· 8 8 var = builtins.getEnv name; 9 9 in 10 10 if var == "" 11 - then throw "\$${name} must be defined, see docs/hacking.md for more details" 11 + then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 12 else var; 13 13 envVarOr = name: default: let 14 14 var = builtins.getEnv name;
+3 -3
readme.md
··· 10 10 11 11 ## docs 12 12 13 - * [knot hosting guide](/docs/knot-hosting.md) 14 - * [contributing guide](/docs/contributing.md) **please read before opening a PR!** 15 - * [hacking on tangled](/docs/hacking.md) 13 + - [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide) 14 + - [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!** 15 + - [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled) 16 16 17 17 ## security 18 18
+10 -3
spindle/engine/engine.go
··· 36 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 37 37 38 38 for _, w := range wfs { 39 - wg.Go(func() { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 40 43 wid := models.WorkflowId{ 41 44 PipelineId: pipelineId, 42 45 Name: w.Name, ··· 67 70 } 68 71 defer eng.DestroyWorkflow(ctx, wid) 69 72 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 71 78 if err != nil { 72 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 80 wfLogger = nil ··· 115 122 if err != nil { 116 123 l.Error("failed to set workflow status to success", "wid", wid, "err", err) 117 124 } 118 - }) 125 + }() 119 126 } 120 127 } 121 128
+6 -1
spindle/models/logger.go
··· 12 12 type WorkflowLogger struct { 13 13 file *os.File 14 14 encoder *json.Encoder 15 + mask *SecretMask 15 16 } 16 17 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 18 19 path := LogFilePath(baseDir, wid) 19 20 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 26 return &WorkflowLogger{ 26 27 file: file, 27 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 28 30 }, nil 29 31 } 30 32 ··· 62 64 63 65 func (w *dataWriter) Write(p []byte) (int, error) { 64 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 65 70 entry := NewDataLogLine(w.idx, line, w.stream) 66 71 if err := w.logger.encoder.Encode(entry); err != nil { 67 72 return 0, err
+51
spindle/models/secret_mask.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+1 -1
spindle/motd
··· 20 20 ** 21 21 ******** 22 22 23 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 23 + This is a spindle server. More info at https://docs.tangled.org/spindles.html#spindles 24 24 25 25 Most API routes are under /xrpc/
+31 -13
spindle/server.go
··· 8 8 "log/slog" 9 9 "maps" 10 10 "net/http" 11 + "sync" 11 12 12 13 "github.com/go-chi/chi/v5" 13 14 "tangled.org/core/api/tangled" ··· 30 31 ) 31 32 32 33 //go:embed motd 33 - var motd []byte 34 + var defaultMotd []byte 34 35 35 36 const ( 36 37 rbacDomain = "thisserver" 37 38 ) 38 39 39 40 type Spindle struct { 40 - jc *jetstream.JetstreamClient 41 - db *db.DB 42 - e *rbac.Enforcer 43 - l *slog.Logger 44 - n *notifier.Notifier 45 - engs map[string]models.Engine 46 - jq *queue.Queue 47 - cfg *config.Config 48 - ks *eventconsumer.Consumer 49 - res *idresolver.Resolver 50 - vault secrets.Manager 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 51 54 } 52 55 53 56 // New creates a new Spindle server with the provided configuration and engines. ··· 128 131 cfg: cfg, 129 132 res: resolver, 130 133 vault: vault, 134 + motd: defaultMotd, 131 135 } 132 136 133 137 err = e.AddSpindle(rbacDomain) ··· 201 205 return s.e 202 206 } 203 207 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 204 222 // Start starts the Spindle server (blocking). 205 223 func (s *Spindle) Start(ctx context.Context) error { 206 224 // starts a job queue runner in the background ··· 246 264 mux := chi.NewRouter() 247 265 248 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 249 - w.Write(motd) 267 + w.Write(s.GetMotdContent()) 250 268 }) 251 269 mux.HandleFunc("/events", s.Events) 252 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
+1 -1
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 6 darkMode: "media", 7 7 theme: { 8 8 container: {
+3
types/diff.go
··· 74 74 75 75 // used by html elements as a unique ID for hrefs 76 76 func (d *Diff) Id() string { 77 + if d.IsDelete { 78 + return d.Name.Old 79 + } 77 80 return d.Name.New 78 81 } 79 82
+112
types/diff_test.go
··· 1 + package types 2 + 3 + import "testing" 4 + 5 + func TestDiffId(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + diff Diff 9 + expected string 10 + }{ 11 + { 12 + name: "regular file uses new name", 13 + diff: Diff{ 14 + Name: struct { 15 + Old string `json:"old"` 16 + New string `json:"new"` 17 + }{Old: "", New: "src/main.go"}, 18 + }, 19 + expected: "src/main.go", 20 + }, 21 + { 22 + name: "new file uses new name", 23 + diff: Diff{ 24 + Name: struct { 25 + Old string `json:"old"` 26 + New string `json:"new"` 27 + }{Old: "", New: "src/new.go"}, 28 + IsNew: true, 29 + }, 30 + expected: "src/new.go", 31 + }, 32 + { 33 + name: "deleted file uses old name", 34 + diff: Diff{ 35 + Name: struct { 36 + Old string `json:"old"` 37 + New string `json:"new"` 38 + }{Old: "src/deleted.go", New: ""}, 39 + IsDelete: true, 40 + }, 41 + expected: "src/deleted.go", 42 + }, 43 + { 44 + name: "renamed file uses new name", 45 + diff: Diff{ 46 + Name: struct { 47 + Old string `json:"old"` 48 + New string `json:"new"` 49 + }{Old: "src/old.go", New: "src/renamed.go"}, 50 + IsRename: true, 51 + }, 52 + expected: "src/renamed.go", 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + if got := tt.diff.Id(); got != tt.expected { 59 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 60 + } 61 + }) 62 + } 63 + } 64 + 65 + func TestChangedFilesMatchesDiffId(t *testing.T) { 66 + // ChangedFiles() must return values matching each Diff's Id() 67 + // so that sidebar links point to the correct anchors. 68 + // Tests existing, deleted, new, and renamed files. 69 + nd := NiceDiff{ 70 + Diff: []Diff{ 71 + { 72 + Name: struct { 73 + Old string `json:"old"` 74 + New string `json:"new"` 75 + }{Old: "", New: "src/modified.go"}, 76 + }, 77 + { 78 + Name: struct { 79 + Old string `json:"old"` 80 + New string `json:"new"` 81 + }{Old: "src/deleted.go", New: ""}, 82 + IsDelete: true, 83 + }, 84 + { 85 + Name: struct { 86 + Old string `json:"old"` 87 + New string `json:"new"` 88 + }{Old: "", New: "src/new.go"}, 89 + IsNew: true, 90 + }, 91 + { 92 + Name: struct { 93 + Old string `json:"old"` 94 + New string `json:"new"` 95 + }{Old: "src/old.go", New: "src/renamed.go"}, 96 + IsRename: true, 97 + }, 98 + }, 99 + } 100 + 101 + changedFiles := nd.ChangedFiles() 102 + 103 + if len(changedFiles) != len(nd.Diff) { 104 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 105 + } 106 + 107 + for i, diff := range nd.Diff { 108 + if changedFiles[i] != diff.Id() { 109 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 110 + } 111 + } 112 + }