Compare changes

Choose any two refs to compare.

+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
+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/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>
+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
+5
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.Post("/account/switch", s.SwitchAccount) 136 + r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) 133 137 134 138 r.Route("/repo", func(r chi.Router) { 135 139 r.Route("/new", func(r chi.Router) { ··· 182 186 r.Get("/brand", s.Brand) 183 187 184 188 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 189 + w.WriteHeader(http.StatusNotFound) 185 190 s.pages.Error404(w) 186 191 }) 187 192 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) {
+86 -89
docs/DOCS.md
··· 1 1 --- 2 - title: Tangled Documentation 2 + title: Tangled docs 3 3 author: The Tangled Contributors 4 4 date: 21 Sun, Dec 2025 5 - --- 6 - 7 - # Introduction 8 - 9 - Tangled is a decentralized code hosting and collaboration 10 - platform. Every component of Tangled is open-source and 11 - selfhostable. [tangled.org](https://tangled.org) also 12 - provides hosting and CI services that are free to use. 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. 13 10 14 - There are several models for decentralized code 15 - collaboration platforms, ranging from ActivityPubโ€™s 16 - (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 17 - Our approach attempts to be the best of both worlds by 18 - adopting atprotoโ€”a protocol for building decentralized 19 - social applications with a central identity 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 20 17 21 - Our approach to this is the idea of โ€œknotsโ€. Knots are 22 - lightweight, headless servers that enable users to host Git 23 - repositories with ease. Knots are designed for either single 24 - or multi-tenant use which is perfect for self-hosting on a 25 - Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 26 - default, Tangled provides managed knots where you can host 27 - your repositories for free. 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. 28 25 29 - The "appview" at tangled.org acts as a consolidated โ€œviewโ€ 30 - into the whole network, allowing users to access, clone and 31 - contribute to repositories hosted across different knots 32 - seamlessly. 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 + --- 33 31 34 - # Quick Start Guide 32 + # Quick start guide 35 33 36 - ## Login or Sign up 34 + ## Login or sign up 37 35 38 - You can [login](https://tangled.org) by using your AT 36 + You can [login](https://tangled.org) by using your AT Protocol 39 37 account. If you are unclear on what that means, simply head 40 38 to the [signup](https://tangled.org/signup) page and create 41 39 an account. By doing so, you will be choosing Tangled as 42 40 your account provider (you will be granted a handle of the 43 41 form `user.tngl.sh`). 44 42 45 - In the AT network, users are free to choose their account 43 + In the AT Protocol network, users are free to choose their account 46 44 provider (known as a "Personal Data Service", or PDS), and 47 45 login to applications that support AT accounts. 48 46 49 - You can think of it as "one account for all of the 50 - atmosphere"! 47 + You can think of it as "one account for all of the atmosphere"! 51 48 52 49 If you already have an AT account (you may have one if you 53 50 signed up to Bluesky, for example), you can login with the 54 51 same handle on Tangled (so just use `user.bsky.social` on 55 52 the login page). 56 53 57 - ## Add an SSH Key 54 + ## Add an SSH key 58 55 59 56 Once you are logged in, you can start creating repositories 60 57 and pushing code. Tangled supports pushing git repositories ··· 87 84 paste your public key, give it a descriptive name, and hit 88 85 save. 89 86 90 - ## Create a Repository 87 + ## Create a repository 91 88 92 89 Once your SSH key is added, create your first repository: 93 90 ··· 98 95 4. Choose a knotserver to host this repository on 99 96 5. Hit create 100 97 101 - "Knots" are selfhostable, lightweight git servers that can 98 + Knots are self-hostable, lightweight Git servers that can 102 99 host your repository. Unlike traditional code forges, your 103 100 code can live on any server. Read the [Knots](TODO) section 104 101 for more. ··· 125 122 are hosted by tangled.org. If you use a custom knot, refer 126 123 to the [Knots](TODO) section. 127 124 128 - ## Push Your First Repository 125 + ## Push your first repository 129 126 130 - Initialize a new git repository: 127 + Initialize a new Git repository: 131 128 132 129 ```bash 133 130 mkdir my-project ··· 165 162 cd /path/to/your/existing/repo 166 163 ``` 167 164 168 - You can inspect your existing git remote like so: 165 + You can inspect your existing Git remote like so: 169 166 170 167 ```bash 171 168 git remote -v ··· 197 194 origin git@tangled.org:user.tngl.sh/my-project (push) 198 195 ``` 199 196 200 - Push all your branches and tags to tangled: 197 + Push all your branches and tags to Tangled: 201 198 202 199 ```bash 203 200 git push -u origin --all ··· 232 229 ``` 233 230 234 231 You also need to re-add the original URL as a push 235 - destination (git replaces the push URL when you use `--add` 232 + destination (Git replaces the push URL when you use `--add` 236 233 the first time): 237 234 238 235 ```bash ··· 249 246 ``` 250 247 251 248 Notice that there's one fetch URL (the primary remote) and 252 - two push URLs. Now, whenever you push, git will 249 + two push URLs. Now, whenever you push, Git will 253 250 automatically push to both remotes: 254 251 255 252 ```bash ··· 301 298 ## Docker 302 299 303 300 Refer to 304 - [@tangled.org/knot-docker](https://tangled.sh/@tangled.sh/knot-docker). 301 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 305 302 Note that this is community maintained. 306 303 307 304 ## Manual setup ··· 372 369 ``` 373 370 KNOT_REPO_SCAN_PATH=/home/git 374 371 KNOT_SERVER_HOSTNAME=knot.example.com 375 - APPVIEW_ENDPOINT=https://tangled.sh 372 + APPVIEW_ENDPOINT=https://tangled.org 376 373 KNOT_SERVER_OWNER=did:plc:foobar 377 374 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 378 375 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 ··· 603 600 - `nixery`: This uses an instance of 604 601 [Nixery](https://nixery.dev) to run steps, which allows 605 602 you to add [dependencies](#dependencies) from 606 - [Nixpkgs](https://github.com/NixOS/nixpkgs). You can 603 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 607 604 search for packages on https://search.nixos.org, and 608 605 there's a pretty good chance the package(s) you're looking 609 606 for will be there. ··· 630 627 default, the depth is set to 1, meaning only the most 631 628 recent commit will be fetched, which is the commit that 632 629 triggered the workflow. 633 - - `submodules`: If you use [git 634 - submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) 630 + - `submodules`: If you use Git submodules 631 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 635 632 in your repository, setting this field to `true` will 636 633 recursively fetch all submodules. This is `false` by 637 634 default. ··· 657 654 Say you want to fetch Node.js and Go from `nixpkgs`, and a 658 655 package called `my_pkg` you've made from your own registry 659 656 at your repository at 660 - `https://tangled.sh/@example.com/my_pkg`. You can define 657 + `https://tangled.org/@example.com/my_pkg`. You can define 661 658 those dependencies like so: 662 659 663 660 ```yaml ··· 779 776 780 777 If you want another example of a workflow, you can look at 781 778 the one [Tangled uses to build the 782 - project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml). 779 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 783 780 784 781 ## Self-hosting guide 785 782 ··· 836 833 837 834 ## Architecture 838 835 839 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 836 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 840 837 841 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 838 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 842 839 [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 843 - * when a new repo record comes through (typically when you add a spindle to a 840 + * When a new repo record comes through (typically when you add a spindle to a 844 841 repo from the settings), spindle then resolves the underlying knot and 845 842 subscribes to repo events (see: 846 843 [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 847 - * the spindle engine then handles execution of the pipeline, with results and 848 - logs beamed on the spindle event stream over wss 844 + * The spindle engine then handles execution of the pipeline, with results and 845 + logs beamed on the spindle event stream over WebSocket 849 846 850 847 ### The engine 851 848 852 849 At present, the only supported backend is Docker (and Podman, if Docker 853 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 850 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 854 851 executes each step in the pipeline in a fresh container, with state persisted 855 852 across steps within the `/tangled/workspace` directory. 856 853 ··· 858 855 [Nixery](https://nixery.dev), which is handy for caching layers for frequently 859 856 used packages. 860 857 861 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md). 858 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 862 859 863 860 ## Secrets with openbao 864 861 865 - This document covers setting up Spindle to use OpenBao for secrets 862 + This document covers setting up spindle to use OpenBao for secrets 866 863 management via OpenBao Proxy instead of the default SQLite backend. 867 864 868 865 ### Overview 869 866 870 867 Spindle now uses OpenBao Proxy for secrets management. The proxy handles 871 - authentication automatically using AppRole credentials, while Spindle 868 + authentication automatically using AppRole credentials, while spindle 872 869 connects to the local proxy instead of directly to the OpenBao server. 873 870 874 871 This approach provides better security, automatic token renewal, and ··· 876 873 877 874 ### Installation 878 875 879 - Install OpenBao from nixpkgs: 876 + Install OpenBao from Nixpkgs: 880 877 881 878 ```bash 882 879 nix shell nixpkgs#openbao # for a local server ··· 1029 1026 } 1030 1027 } 1031 1028 1032 - # Proxy listener for Spindle 1029 + # Proxy listener for spindle 1033 1030 listener "tcp" { 1034 1031 address = "127.0.0.1:8201" 1035 1032 tls_disable = true ··· 1062 1059 1063 1060 #### Configure spindle 1064 1061 1065 - Set these environment variables for Spindle: 1062 + Set these environment variables for spindle: 1066 1063 1067 1064 ```bash 1068 1065 export SPINDLE_SERVER_SECRETS_PROVIDER=openbao ··· 1070 1067 export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1071 1068 ``` 1072 1069 1073 - On startup, the spindle will now connect to the local proxy, 1070 + On startup, spindle will now connect to the local proxy, 1074 1071 which handles all authentication automatically. 1075 1072 1076 1073 ### Production setup for proxy ··· 1099 1096 # List all secrets 1100 1097 bao kv list spindle/ 1101 1098 1102 - # Add a test secret via Spindle API, then check it exists 1099 + # Add a test secret via the spindle API, then check it exists 1103 1100 bao kv list spindle/repos/ 1104 1101 1105 1102 # Get a specific secret ··· 1112 1109 port 8200 or 8201) 1113 1110 - The proxy authenticates with OpenBao using AppRole 1114 1111 credentials 1115 - - All Spindle requests go through the proxy, which injects 1112 + - All spindle requests go through the proxy, which injects 1116 1113 authentication tokens 1117 1114 - Secrets are stored at 1118 1115 `spindle/repos/{sanitized_repo_path}/{secret_key}` ··· 1131 1128 and the policy has the necessary permissions. 1132 1129 1133 1130 **404 route errors**: The spindle KV mount probably doesn't 1134 - exist - run the mount creation step again. 1131 + existโ€”run the mount creation step again. 1135 1132 1136 1133 **Proxy authentication failures**: Check the proxy logs and 1137 1134 verify the role-id and secret-id files are readable and ··· 1159 1156 secret_id="$(cat /tmp/openbao/secret-id)" 1160 1157 ``` 1161 1158 1162 - # Migrating knots & spindles 1159 + # Migrating knots and spindles 1163 1160 1164 1161 Sometimes, non-backwards compatible changes are made to the 1165 1162 knot/spindle XRPC APIs. If you host a knot or a spindle, you ··· 1172 1169 1173 1170 ## Upgrading from v1.8.x 1174 1171 1175 - After v1.8.2, the HTTP API for knot and spindles have been 1172 + After v1.8.2, the HTTP API for knots and spindles has been 1176 1173 deprecated and replaced with XRPC. Repositories on outdated 1177 1174 knots will not be viewable from the appview. Upgrading is 1178 1175 straightforward however. 1179 1176 1180 1177 For knots: 1181 1178 1182 - - Upgrade to latest tag (v1.9.0 or above) 1179 + - Upgrade to the latest tag (v1.9.0 or above) 1183 1180 - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1184 1181 hit the "retry" button to verify your knot 1185 1182 1186 1183 For spindles: 1187 1184 1188 - - Upgrade to latest tag (v1.9.0 or above) 1185 + - Upgrade to the latest tag (v1.9.0 or above) 1189 1186 - Head to the [spindle 1190 1187 dashboard](https://tangled.org/settings/spindles) and hit the 1191 1188 "retry" button to verify your spindle ··· 1227 1224 # Hacking on Tangled 1228 1225 1229 1226 We highly recommend [installing 1230 - nix](https://nixos.org/download/) (the package manager) 1231 - before working on the codebase. The nix flake provides a lot 1227 + Nix](https://nixos.org/download/) (the package manager) 1228 + before working on the codebase. The Nix flake provides a lot 1232 1229 of helpers to get started and most importantly, builds and 1233 1230 dev shells are entirely deterministic. 1234 1231 ··· 1238 1235 nix develop 1239 1236 ``` 1240 1237 1241 - Non-nix users can look at the `devShell` attribute in the 1238 + Non-Nix users can look at the `devShell` attribute in the 1242 1239 `flake.nix` file to determine necessary dependencies. 1243 1240 1244 1241 ## Running the appview 1245 1242 1246 - The nix flake also exposes a few `app` attributes (run `nix 1243 + The Nix flake also exposes a few `app` attributes (run `nix 1247 1244 flake show` to see a full list of what the flake provides), 1248 1245 one of the apps runs the appview with the `air` 1249 1246 live-reloader: ··· 1258 1255 nix run .#watch-tailwind 1259 1256 ``` 1260 1257 1261 - To authenticate with the appview, you will need redis and 1262 - OAUTH JWKs to be setup: 1258 + To authenticate with the appview, you will need Redis and 1259 + OAuth JWKs to be set up: 1263 1260 1264 1261 ``` 1265 - # oauth jwks should already be setup by the nix devshell: 1262 + # OAuth JWKs should already be set up by the Nix devshell: 1266 1263 echo $TANGLED_OAUTH_CLIENT_SECRET 1267 1264 z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1268 1265 ··· 1280 1277 # the secret key from above 1281 1278 export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1282 1279 1283 - # run redis in at a new shell to store oauth sessions 1280 + # Run Redis in a new shell to store OAuth sessions 1284 1281 redis-server 1285 1282 ``` 1286 1283 1287 1284 ## Running knots and spindles 1288 1285 1289 1286 An end-to-end knot setup requires setting up a machine with 1290 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 1291 - quite cumbersome. So the nix flake provides a 1287 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1288 + quite cumbersome. So the Nix flake provides a 1292 1289 `nixosConfiguration` to do so. 1293 1290 1294 1291 <details> 1295 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 1292 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1296 1293 1297 1294 In order to build Tangled's dev VM on macOS, you will 1298 1295 first need to set up a Linux Nix builder. The recommended ··· 1303 1300 you are using Apple Silicon). 1304 1301 1305 1302 > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1306 - > the tangled repo so that it doesn't conflict with the other VM. For example, 1303 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1307 1304 > you can do 1308 1305 > 1309 1306 > ```shell ··· 1316 1313 > avoid subtle problems. 1317 1314 1318 1315 Alternatively, you can use any other method to set up a 1319 - Linux machine with `nix` installed that you can `sudo ssh` 1316 + Linux machine with Nix installed that you can `sudo ssh` 1320 1317 into (in other words, root user on your Mac has to be able 1321 1318 to ssh into the Linux machine without entering a password) 1322 1319 and that has the same architecture as your Mac. See ··· 1347 1344 with `ssh` exposed on port 2222. 1348 1345 1349 1346 Once the services are running, head to 1350 - http://localhost:3000/settings/knots and hit verify. It should 1347 + http://localhost:3000/settings/knots and hit "Verify". It should 1351 1348 verify the ownership of the services instantly if everything 1352 1349 went smoothly. 1353 1350 ··· 1371 1368 1372 1369 The above VM should already be running a spindle on 1373 1370 `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1374 - hit verify. You can then configure each repository to use 1371 + hit "Verify". You can then configure each repository to use 1375 1372 this spindle and run CI jobs. 1376 1373 1377 1374 Of interest when debugging spindles: 1378 1375 1379 1376 ``` 1380 - # service logs from journald: 1377 + # Service logs from journald: 1381 1378 journalctl -xeu spindle 1382 1379 1383 1380 # CI job logs from disk: 1384 1381 ls /var/log/spindle 1385 1382 1386 - # debugging spindle db: 1383 + # Debugging spindle database: 1387 1384 sqlite3 /var/lib/spindle/spindle.db 1388 1385 1389 1386 # litecli has a nicer REPL interface: ··· 1432 1429 1433 1430 ### General notes 1434 1431 1435 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 1436 - using `git am`. At present, there is no squashing -- so please author 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 1437 1434 your commits as they would appear on `master`, following the above 1438 1435 guidelines. 1439 1436 - If there is a lot of nesting, for example "appview: ··· 1454 1451 ## Code formatting 1455 1452 1456 1453 We use a variety of tools to format our code, and multiplex them with 1457 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 1454 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1458 1455 is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1459 1456 1460 1457 ## Proposals for bigger changes ··· 1482 1479 We'll use the issue thread to discuss and refine the idea before moving 1483 1480 forward. 1484 1481 1485 - ## Developer certificate of origin (DCO) 1482 + ## Developer Certificate of Origin (DCO) 1486 1483 1487 1484 We require all contributors to certify that they have the right to 1488 1485 submit the code they're contributing. To do this, we follow the
+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>
+80 -41
docs/template.html
··· 20 20 <meta name="description" content="$description-meta$" /> 21 21 $endif$ 22 22 23 - <title>$pagetitle$ - Tangled docs</title> 23 + <title>$pagetitle$</title> 24 24 25 25 <style> 26 26 $styles.css()$ ··· 37 37 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 38 39 39 </head> 40 - <body class="bg-white dark:bg-gray-900 min-h-screen"> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 41 $for(include-before)$ 42 42 $include-before$ 43 43 $endfor$ 44 + 44 45 $if(toc)$ 45 - <!-- mobile topbar toc --> 46 - <details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4"> 47 - <summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white"> 48 - $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 49 - <span class="group-open:hidden inline">${ menu.svg() }</span> 50 - <span class="hidden group-open:inline">${ x.svg() }</span> 51 - </summary> 52 - ${ table-of-contents:toc.html() } 53 - </details> 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> 54 83 55 - <!-- desktop sidebar toc --> 56 - <nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50"> 57 - $if(toc-title)$ 58 - <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 59 - $endif$ 60 - ${ table-of-contents:toc.html() } 61 - </nav> 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> 62 100 $endif$ 63 101 64 - <div class="min-h-screen flex-1 flex flex-col $if(toc)$md:ml-80$endif$"> 65 - <main class="flex-1 max-w-4xl w-full mx-auto p-6"> 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"> 66 104 $if(top)$ 67 - $-- only print title block if this is NOT the top page 105 + $-- only print title block if this is NOT the top page 68 106 $else$ 69 107 $if(title)$ 70 - <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 71 - <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 72 - $if(subtitle)$ 73 - <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 74 - $endif$ 75 - $for(author)$ 76 - <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 77 - $endfor$ 78 - $if(date)$ 79 - <p class="text-sm text-gray-500 dark:text-gray-400">$date$</p> 80 - $endif$ 81 - $if(abstract)$ 82 - <div class="mt-6 p-4 bg-gray-50 rounded-lg"> 83 - <div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div> 84 - <div class="text-gray-700">$abstract$</div> 85 - </div> 86 - $endif$ 87 - $endif$ 88 - </header> 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> 89 121 $endif$ 122 + 123 + $if(abstract)$ 124 + <article class="prose dark:prose-invert max-w-none"> 125 + $abstract$ 126 + </article> 127 + $endif$ 128 + 90 129 <article class="prose dark:prose-invert max-w-none"> 91 130 $body$ 92 131 </article> 93 132 </main> 94 - <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 "> 133 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 95 134 <div class="max-w-4xl mx-auto px-8 py-4"> 96 135 <div class="flex justify-between gap-4"> 97 136 <span class="flex-1">
+1 -1
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 {
+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",
+13 -1
nix/pkgs/docs.nix
··· 18 18 # icons 19 19 cp -rf ${lucide-src}/*.svg working/ 20 20 21 - # content 21 + # content - chunked 22 22 ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 23 -o $out/ \ 24 24 -t chunkedhtml \ 25 25 --variable toc \ 26 + --variable-json single-page=false \ 26 27 --toc-depth=2 \ 27 28 --css=stylesheet.css \ 28 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 \ 29 41 --highlight-style=working/highlight.theme \ 30 42 --template=working/template.html 31 43
+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
+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)
+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 + }