+79
-20
api/tangled/cbor_gen.go
+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
+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
+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
+1
-1
appview/db/punchcard.go
+1
appview/db/repos.go
+1
appview/db/repos.go
+32
-32
appview/issues/issues.go
+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
+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
+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
+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
+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
+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
+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), ®istry); err != nil {
57
+
return &AccountRegistry{Accounts: []AccountInfo{}}
58
+
}
59
+
60
+
return ®istry
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
+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
+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
+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
+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
+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
+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
+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
+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/knots/index.html
+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>
+49
-11
appview/pages/templates/layouts/fragments/topbar.html
+49
-11
appview/pages/templates/layouts/fragments/topbar.html
···
49
49
{{ define "profileDropdown" }}
50
50
<details class="relative inline-block text-left nav-dropdown">
51
51
<summary class="cursor-pointer list-none flex items-center gap-1">
52
-
{{ $user := .Did }}
52
+
{{ $user := .Active.Did }}
53
53
<img
54
54
src="{{ tinyAvatar $user }}"
55
55
alt=""
···
57
57
/>
58
58
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
59
59
</summary>
60
-
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
61
-
<a href="/{{ $user }}">profile</a>
62
-
<a href="/{{ $user }}?tab=repos">repositories</a>
63
-
<a href="/{{ $user }}?tab=strings">strings</a>
64
-
<a href="/settings">settings</a>
65
-
<a href="#"
66
-
hx-post="/logout"
67
-
hx-swap="none"
68
-
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
69
-
logout
60
+
<div class="absolute right-0 mt-4 p-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50" style="width: 14rem;">
61
+
{{ $active := .Active.Did }}
62
+
63
+
<div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700">
64
+
<div class="flex items-center gap-2">
65
+
<img src="{{ tinyAvatar $active }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" />
66
+
<div class="flex-1 overflow-hidden">
67
+
<p class="font-medium text-sm truncate">{{ $active | resolve }}</p>
68
+
<p class="text-xs text-green-600 dark:text-green-400">active</p>
69
+
</div>
70
+
</div>
71
+
</div>
72
+
73
+
{{ $others := .Accounts | otherAccounts $active }}
74
+
{{ if $others }}
75
+
<div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700">
76
+
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Switch Account</p>
77
+
{{ range $others }}
78
+
<button
79
+
type="button"
80
+
hx-post="/account/switch"
81
+
hx-vals='{"did": "{{ .Did }}"}'
82
+
hx-swap="none"
83
+
class="flex items-center gap-2 w-full py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
84
+
>
85
+
<img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-6 w-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" />
86
+
<span class="text-sm truncate flex-1">{{ .Did | resolve }}</span>
87
+
</button>
88
+
{{ end }}
89
+
</div>
90
+
{{ end }}
91
+
92
+
<a href="/login?mode=add_account" class="flex items-center gap-2 py-1 text-sm">
93
+
{{ i "plus" "w-4 h-4 flex-shrink-0" }}
94
+
<span>Add another account</span>
70
95
</a>
96
+
97
+
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1">
98
+
<a href="/{{ $active }}" class="block py-1 text-sm">profile</a>
99
+
<a href="/{{ $active }}?tab=repos" class="block py-1 text-sm">repositories</a>
100
+
<a href="/{{ $active }}?tab=strings" class="block py-1 text-sm">strings</a>
101
+
<a href="/settings" class="block py-1 text-sm">settings</a>
102
+
<a href="#"
103
+
hx-post="/logout"
104
+
hx-swap="none"
105
+
class="block py-1 text-sm text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
106
+
logout
107
+
</a>
108
+
</div>
71
109
</div>
72
110
</details>
73
111
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
···
26
26
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
27
{{ $knot := .RepoInfo.Knot }}
28
28
{{ if eq $knot "knot1.tangled.sh" }}
29
-
{{ $knot = "tangled.sh" }}
29
+
{{ $knot = "tangled.org" }}
30
30
{{ end }}
31
31
<div class="w-full flex place-content-center">
32
32
<div class="py-6 w-fit flex flex-col gap-4">
+1
-1
appview/pages/templates/repo/fragments/diff.html
+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
+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">···</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">···</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">···</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">···</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
+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">···</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">···</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
+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
+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
+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
+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
+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
+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
+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
+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
+1
-1
appview/repo/blob.go
+1
-1
appview/repo/branches.go
+1
-1
appview/repo/branches.go
+2
-2
appview/repo/compare.go
+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
+1
-1
appview/repo/index.go
+2
-2
appview/repo/log.go
+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
+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
+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/tree.go
+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
+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
+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
+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
+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
+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
+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
+1
-1
appview/state/gfi.go
+57
-7
appview/state/login.go
+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
+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 ¶ms, 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
+7
-7
appview/state/reaction.go
···
17
17
)
18
18
19
19
func (s *State) React(w http.ResponseWriter, r *http.Request) {
20
-
currentUser := s.oauth.GetUser(r)
20
+
currentUser := s.oauth.GetMultiAccountUser(r)
21
21
22
22
subject := r.URL.Query().Get("subject")
23
23
if subject == "" {
···
49
49
rkey := tid.TID()
50
50
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
51
51
Collection: tangled.FeedReactionNSID,
52
-
Repo: currentUser.Did,
52
+
Repo: currentUser.Active.Did,
53
53
Rkey: rkey,
54
54
Record: &lexutil.LexiconTypeDecoder{
55
55
Val: &tangled.FeedReaction{
···
64
64
return
65
65
}
66
66
67
-
err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey)
67
+
err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey)
68
68
if err != nil {
69
69
log.Println("failed to react", err)
70
70
return
···
87
87
88
88
return
89
89
case http.MethodDelete:
90
-
reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind)
90
+
reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind)
91
91
if err != nil {
92
-
log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri)
92
+
log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri)
93
93
return
94
94
}
95
95
96
96
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
97
97
Collection: tangled.FeedReactionNSID,
98
-
Repo: currentUser.Did,
98
+
Repo: currentUser.Active.Did,
99
99
Rkey: reaction.Rkey,
100
100
})
101
101
···
104
104
return
105
105
}
106
106
107
-
err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey)
107
+
err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey)
108
108
if err != nil {
109
109
log.Println("failed to delete reaction from DB")
110
110
// this is not an issue, the firehose event might have already done this
+7
appview/state/router.go
+7
appview/state/router.go
···
109
109
})
110
110
111
111
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
112
+
w.WriteHeader(http.StatusNotFound)
112
113
s.pages.Error404(w)
113
114
})
114
115
···
130
131
r.Get("/login", s.Login)
131
132
r.Post("/login", s.Login)
132
133
r.Post("/logout", s.Logout)
134
+
135
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/account", func(r chi.Router) {
136
+
r.Post("/switch", s.SwitchAccount)
137
+
r.Delete("/{did}", s.RemoveAccount)
138
+
})
133
139
134
140
r.Route("/repo", func(r chi.Router) {
135
141
r.Route("/new", func(r chi.Router) {
···
182
188
r.Get("/brand", s.Brand)
183
189
184
190
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
191
+
w.WriteHeader(http.StatusNotFound)
185
192
s.pages.Error404(w)
186
193
})
187
194
return r
+6
-6
appview/state/star.go
+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
+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
+19
-19
appview/strings/strings.go
···
82
82
}
83
83
84
84
s.Pages.StringsTimeline(w, pages.StringTimelineParams{
85
-
LoggedInUser: s.OAuth.GetUser(r),
85
+
LoggedInUser: s.OAuth.GetMultiAccountUser(r),
86
86
Strings: strings,
87
87
})
88
88
}
···
153
153
if err != nil {
154
154
l.Error("failed to get star count", "err", err)
155
155
}
156
-
user := s.OAuth.GetUser(r)
156
+
user := s.OAuth.GetMultiAccountUser(r)
157
157
isStarred := false
158
158
if user != nil {
159
-
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
159
+
isStarred = db.GetStarStatus(s.Db, user.Active.Did, string.AtUri())
160
160
}
161
161
162
162
s.Pages.SingleString(w, pages.SingleStringParams{
···
178
178
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
179
179
l := s.Logger.With("handler", "edit")
180
180
181
-
user := s.OAuth.GetUser(r)
181
+
user := s.OAuth.GetMultiAccountUser(r)
182
182
183
183
id, ok := r.Context().Value("resolvedId").(identity.Identity)
184
184
if !ok {
···
216
216
first := all[0]
217
217
218
218
// verify that the logged in user owns this string
219
-
if user.Did != id.DID.String() {
220
-
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
219
+
if user.Active.Did != id.DID.String() {
220
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Active.Did)
221
221
w.WriteHeader(http.StatusUnauthorized)
222
222
return
223
223
}
···
226
226
case http.MethodGet:
227
227
// return the form with prefilled fields
228
228
s.Pages.PutString(w, pages.PutStringParams{
229
-
LoggedInUser: s.OAuth.GetUser(r),
229
+
LoggedInUser: s.OAuth.GetMultiAccountUser(r),
230
230
Action: "edit",
231
231
String: first,
232
232
})
···
299
299
s.Notifier.EditString(r.Context(), &entry)
300
300
301
301
// if that went okay, redir to the string
302
-
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
302
+
s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+entry.Rkey)
303
303
}
304
304
305
305
}
306
306
307
307
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
308
308
l := s.Logger.With("handler", "create")
309
-
user := s.OAuth.GetUser(r)
309
+
user := s.OAuth.GetMultiAccountUser(r)
310
310
311
311
switch r.Method {
312
312
case http.MethodGet:
313
313
s.Pages.PutString(w, pages.PutStringParams{
314
-
LoggedInUser: s.OAuth.GetUser(r),
314
+
LoggedInUser: s.OAuth.GetMultiAccountUser(r),
315
315
Action: "new",
316
316
})
317
317
case http.MethodPost:
···
335
335
description := r.FormValue("description")
336
336
337
337
string := models.String{
338
-
Did: syntax.DID(user.Did),
338
+
Did: syntax.DID(user.Active.Did),
339
339
Rkey: tid.TID(),
340
340
Filename: filename,
341
341
Description: description,
···
353
353
354
354
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
355
355
Collection: tangled.StringNSID,
356
-
Repo: user.Did,
356
+
Repo: user.Active.Did,
357
357
Rkey: string.Rkey,
358
358
Record: &lexutil.LexiconTypeDecoder{
359
359
Val: &record,
···
375
375
s.Notifier.NewString(r.Context(), &string)
376
376
377
377
// successful
378
-
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
378
+
s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+string.Rkey)
379
379
}
380
380
}
381
381
382
382
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
383
383
l := s.Logger.With("handler", "create")
384
-
user := s.OAuth.GetUser(r)
384
+
user := s.OAuth.GetMultiAccountUser(r)
385
385
fail := func(msg string, err error) {
386
386
l.Error(msg, "err", err)
387
387
s.Pages.Notice(w, "error", msg)
···
402
402
return
403
403
}
404
404
405
-
if user.Did != id.DID.String() {
406
-
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
405
+
if user.Active.Did != id.DID.String() {
406
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Active.Did, id.DID.String()))
407
407
return
408
408
}
409
409
410
410
if err := db.DeleteString(
411
411
s.Db,
412
-
orm.FilterEq("did", user.Did),
412
+
orm.FilterEq("did", user.Active.Did),
413
413
orm.FilterEq("rkey", rkey),
414
414
); err != nil {
415
415
fail("Failed to delete string.", err)
416
416
return
417
417
}
418
418
419
-
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
419
+
s.Notifier.DeleteString(r.Context(), user.Active.Did, rkey)
420
420
421
-
s.Pages.HxRedirect(w, "/strings/"+user.Did)
421
+
s.Pages.HxRedirect(w, "/strings/"+user.Active.Did)
422
422
}
423
423
424
424
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+1527
docs/DOCS.md
+1527
docs/DOCS.md
···
1
+
---
2
+
title: Tangled docs
3
+
author: The Tangled Contributors
4
+
date: 21 Sun, Dec 2025
5
+
abstract: |
6
+
Tangled is a decentralized code hosting and collaboration
7
+
platform. Every component of Tangled is open-source and
8
+
self-hostable. [tangled.org](https://tangled.org) also
9
+
provides hosting and CI services that are free to use.
10
+
11
+
There are several models for decentralized code
12
+
collaboration platforms, ranging from ActivityPubโs
13
+
(Forgejo) federated model, to Radicleโs entirely P2P model.
14
+
Our approach attempts to be the best of both worlds by
15
+
adopting the AT Protocolโa protocol for building decentralized
16
+
social applications with a central identity
17
+
18
+
Our approach to this is the idea of โknotsโ. Knots are
19
+
lightweight, headless servers that enable users to host Git
20
+
repositories with ease. Knots are designed for either single
21
+
or multi-tenant use which is perfect for self-hosting on a
22
+
Raspberry Pi at home, or larger โcommunityโ servers. By
23
+
default, Tangled provides managed knots where you can host
24
+
your repositories for free.
25
+
26
+
The appview at tangled.org acts as a consolidated "view"
27
+
into the whole network, allowing users to access, clone and
28
+
contribute to repositories hosted across different knots
29
+
seamlessly.
30
+
---
31
+
32
+
# Quick start guide
33
+
34
+
## Login or sign up
35
+
36
+
You can [login](https://tangled.org) by using your AT Protocol
37
+
account. If you are unclear on what that means, simply head
38
+
to the [signup](https://tangled.org/signup) page and create
39
+
an account. By doing so, you will be choosing Tangled as
40
+
your account provider (you will be granted a handle of the
41
+
form `user.tngl.sh`).
42
+
43
+
In the AT Protocol network, users are free to choose their account
44
+
provider (known as a "Personal Data Service", or PDS), and
45
+
login to applications that support AT accounts.
46
+
47
+
You can think of it as "one account for all of the atmosphere"!
48
+
49
+
If you already have an AT account (you may have one if you
50
+
signed up to Bluesky, for example), you can login with the
51
+
same handle on Tangled (so just use `user.bsky.social` on
52
+
the login page).
53
+
54
+
## Add an SSH key
55
+
56
+
Once you are logged in, you can start creating repositories
57
+
and pushing code. Tangled supports pushing git repositories
58
+
over SSH.
59
+
60
+
First, you'll need to generate an SSH key if you don't
61
+
already have one:
62
+
63
+
```bash
64
+
ssh-keygen -t ed25519 -C "foo@bar.com"
65
+
```
66
+
67
+
When prompted, save the key to the default location
68
+
(`~/.ssh/id_ed25519`) and optionally set a passphrase.
69
+
70
+
Copy your public key to your clipboard:
71
+
72
+
```bash
73
+
# on X11
74
+
cat ~/.ssh/id_ed25519.pub | xclip -sel c
75
+
76
+
# on wayland
77
+
cat ~/.ssh/id_ed25519.pub | wl-copy
78
+
79
+
# on macos
80
+
cat ~/.ssh/id_ed25519.pub | pbcopy
81
+
```
82
+
83
+
Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
84
+
paste your public key, give it a descriptive name, and hit
85
+
save.
86
+
87
+
## Create a repository
88
+
89
+
Once your SSH key is added, create your first repository:
90
+
91
+
1. Hit the green `+` icon on the topbar, and select
92
+
repository
93
+
2. Enter a repository name
94
+
3. Add a description
95
+
4. Choose a knotserver to host this repository on
96
+
5. Hit create
97
+
98
+
Knots are self-hostable, lightweight Git servers that can
99
+
host your repository. Unlike traditional code forges, your
100
+
code can live on any server. Read the [Knots](TODO) section
101
+
for more.
102
+
103
+
## Configure SSH
104
+
105
+
To ensure Git uses the correct SSH key and connects smoothly
106
+
to Tangled, add this configuration to your `~/.ssh/config`
107
+
file:
108
+
109
+
```
110
+
Host tangled.org
111
+
Hostname tangled.org
112
+
User git
113
+
IdentityFile ~/.ssh/id_ed25519
114
+
AddressFamily inet
115
+
```
116
+
117
+
This tells SSH to use your specific key when connecting to
118
+
Tangled and prevents authentication issues if you have
119
+
multiple SSH keys.
120
+
121
+
Note that this configuration only works for knotservers that
122
+
are hosted by tangled.org. If you use a custom knot, refer
123
+
to the [Knots](TODO) section.
124
+
125
+
## Push your first repository
126
+
127
+
Initialize a new Git repository:
128
+
129
+
```bash
130
+
mkdir my-project
131
+
cd my-project
132
+
133
+
git init
134
+
echo "# My Project" > README.md
135
+
```
136
+
137
+
Add some content and push!
138
+
139
+
```bash
140
+
git add README.md
141
+
git commit -m "Initial commit"
142
+
git remote add origin git@tangled.org:user.tngl.sh/my-project
143
+
git push -u origin main
144
+
```
145
+
146
+
That's it! Your code is now hosted on Tangled.
147
+
148
+
## Migrating an existing repository
149
+
150
+
Moving your repositories from GitHub, GitLab, Bitbucket, or
151
+
any other Git forge to Tangled is straightforward. You'll
152
+
simply change your repository's remote URL. At the moment,
153
+
Tangled does not have any tooling to migrate data such as
154
+
GitHub issues or pull requests.
155
+
156
+
First, create a new repository on tangled.org as described
157
+
in the [Quick Start Guide](#create-a-repository).
158
+
159
+
Navigate to your existing local repository:
160
+
161
+
```bash
162
+
cd /path/to/your/existing/repo
163
+
```
164
+
165
+
You can inspect your existing Git remote like so:
166
+
167
+
```bash
168
+
git remote -v
169
+
```
170
+
171
+
You'll see something like:
172
+
173
+
```
174
+
origin git@github.com:username/my-project (fetch)
175
+
origin git@github.com:username/my-project (push)
176
+
```
177
+
178
+
Update the remote URL to point to tangled:
179
+
180
+
```bash
181
+
git remote set-url origin git@tangled.org:user.tngl.sh/my-project
182
+
```
183
+
184
+
Verify the change:
185
+
186
+
```bash
187
+
git remote -v
188
+
```
189
+
190
+
You should now see:
191
+
192
+
```
193
+
origin git@tangled.org:user.tngl.sh/my-project (fetch)
194
+
origin git@tangled.org:user.tngl.sh/my-project (push)
195
+
```
196
+
197
+
Push all your branches and tags to Tangled:
198
+
199
+
```bash
200
+
git push -u origin --all
201
+
git push -u origin --tags
202
+
```
203
+
204
+
Your repository is now migrated to Tangled! All commit
205
+
history, branches, and tags have been preserved.
206
+
207
+
## Mirroring a repository to Tangled
208
+
209
+
If you want to maintain your repository on multiple forges
210
+
simultaneously, for example, keeping your primary repository
211
+
on GitHub while mirroring to Tangled for backup or
212
+
redundancy, you can do so by adding multiple remotes.
213
+
214
+
You can configure your local repository to push to both
215
+
Tangled and, say, GitHub. You may already have the following
216
+
setup:
217
+
218
+
```
219
+
$ git remote -v
220
+
origin git@github.com:username/my-project (fetch)
221
+
origin git@github.com:username/my-project (push)
222
+
```
223
+
224
+
Now add Tangled as an additional push URL to the same
225
+
remote:
226
+
227
+
```bash
228
+
git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
229
+
```
230
+
231
+
You also need to re-add the original URL as a push
232
+
destination (Git replaces the push URL when you use `--add`
233
+
the first time):
234
+
235
+
```bash
236
+
git remote set-url --add --push origin git@github.com:username/my-project
237
+
```
238
+
239
+
Verify your configuration:
240
+
241
+
```
242
+
$ git remote -v
243
+
origin git@github.com:username/repo (fetch)
244
+
origin git@tangled.org:username/my-project (push)
245
+
origin git@github.com:username/repo (push)
246
+
```
247
+
248
+
Notice that there's one fetch URL (the primary remote) and
249
+
two push URLs. Now, whenever you push, Git will
250
+
automatically push to both remotes:
251
+
252
+
```bash
253
+
git push origin main
254
+
```
255
+
256
+
This single command pushes your `main` branch to both GitHub
257
+
and Tangled simultaneously.
258
+
259
+
To push all branches and tags:
260
+
261
+
```bash
262
+
git push origin --all
263
+
git push origin --tags
264
+
```
265
+
266
+
If you prefer more control over which remote you push to,
267
+
you can maintain separate remotes:
268
+
269
+
```bash
270
+
git remote add github git@github.com:username/my-project
271
+
git remote add tangled git@tangled.org:username/my-project
272
+
```
273
+
274
+
Then push to each explicitly:
275
+
276
+
```bash
277
+
git push github main
278
+
git push tangled main
279
+
```
280
+
281
+
# Knot self-hosting guide
282
+
283
+
So you want to run your own knot server? Great! Here are a few prerequisites:
284
+
285
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
286
+
2. A (sub)domain name. People generally use `knot.example.com`.
287
+
3. A valid SSL certificate for your domain.
288
+
289
+
## NixOS
290
+
291
+
Refer to the [knot
292
+
module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
293
+
for a full list of options. Sample configurations:
294
+
295
+
- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
296
+
- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25)
297
+
298
+
## Docker
299
+
300
+
Refer to
301
+
[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
302
+
Note that this is community maintained.
303
+
304
+
## Manual setup
305
+
306
+
First, clone this repository:
307
+
308
+
```
309
+
git clone https://tangled.org/@tangled.org/core
310
+
```
311
+
312
+
Then, build the `knot` CLI. This is the knot administration
313
+
and operation tool. For the purpose of this guide, we're
314
+
only concerned with these subcommands:
315
+
316
+
* `knot server`: the main knot server process, typically
317
+
run as a supervised service
318
+
* `knot guard`: handles role-based access control for git
319
+
over SSH (you'll never have to run this yourself)
320
+
* `knot keys`: fetches SSH keys associated with your knot;
321
+
we'll use this to generate the SSH
322
+
`AuthorizedKeysCommand`
323
+
324
+
```
325
+
cd core
326
+
export CGO_ENABLED=1
327
+
go build -o knot ./cmd/knot
328
+
```
329
+
330
+
Next, move the `knot` binary to a location owned by `root` --
331
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
332
+
333
+
```
334
+
sudo mv knot /usr/local/bin/knot
335
+
sudo chown root:root /usr/local/bin/knot
336
+
```
337
+
338
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really
339
+
specific permissions](https://stackoverflow.com/a/27638306). The
340
+
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
341
+
retrieve a user's public SSH keys dynamically for authentication. Let's
342
+
set that up.
343
+
344
+
```
345
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
346
+
Match User git
347
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
348
+
AuthorizedKeysCommandUser nobody
349
+
EOF
350
+
```
351
+
352
+
Then, reload `sshd`:
353
+
354
+
```
355
+
sudo systemctl reload ssh
356
+
```
357
+
358
+
Next, create the `git` user. We'll use the `git` user's home directory
359
+
to store repositories:
360
+
361
+
```
362
+
sudo adduser git
363
+
```
364
+
365
+
Create `/home/git/.knot.env` with the following, updating the values as
366
+
necessary. The `KNOT_SERVER_OWNER` should be set to your
367
+
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
368
+
369
+
```
370
+
KNOT_REPO_SCAN_PATH=/home/git
371
+
KNOT_SERVER_HOSTNAME=knot.example.com
372
+
APPVIEW_ENDPOINT=https://tangled.org
373
+
KNOT_SERVER_OWNER=did:plc:foobar
374
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
375
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376
+
```
377
+
378
+
If you run a Linux distribution that uses systemd, you can use the provided
379
+
service file to run the server. Copy
380
+
[`knotserver.service`](/systemd/knotserver.service)
381
+
to `/etc/systemd/system/`. Then, run:
382
+
383
+
```
384
+
systemctl enable knotserver
385
+
systemctl start knotserver
386
+
```
387
+
388
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
389
+
knot. Here's an example configuration for Nginx:
390
+
391
+
```
392
+
server {
393
+
listen 80;
394
+
listen [::]:80;
395
+
server_name knot.example.com;
396
+
397
+
location / {
398
+
proxy_pass http://localhost:5555;
399
+
proxy_set_header Host $host;
400
+
proxy_set_header X-Real-IP $remote_addr;
401
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
402
+
proxy_set_header X-Forwarded-Proto $scheme;
403
+
}
404
+
405
+
# wss endpoint for git events
406
+
location /events {
407
+
proxy_set_header X-Forwarded-For $remote_addr;
408
+
proxy_set_header Host $http_host;
409
+
proxy_set_header Upgrade websocket;
410
+
proxy_set_header Connection Upgrade;
411
+
proxy_pass http://localhost:5555;
412
+
}
413
+
# additional config for SSL/TLS go here.
414
+
}
415
+
416
+
```
417
+
418
+
Remember to use Let's Encrypt or similar to procure a certificate for your
419
+
knot domain.
420
+
421
+
You should now have a running knot server! You can finalize
422
+
your registration by hitting the `verify` button on the
423
+
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
424
+
a record on your PDS to announce the existence of the knot.
425
+
426
+
### Custom paths
427
+
428
+
(This section applies to manual setup only. Docker users should edit the mounts
429
+
in `docker-compose.yml` instead.)
430
+
431
+
Right now, the database and repositories of your knot lives in `/home/git`. You
432
+
can move these paths if you'd like to store them in another folder. Be careful
433
+
when adjusting these paths:
434
+
435
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
436
+
any possible side effects. Remember to restart it once you're done.
437
+
* Make backups before moving in case something goes wrong.
438
+
* Make sure the `git` user can read and write from the new paths.
439
+
440
+
#### Database
441
+
442
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
443
+
and we want to move it to `/home/git/database/knotserver.db`.
444
+
445
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
446
+
and `.db-wal` files if they exist.
447
+
448
+
```
449
+
mkdir /home/git/database
450
+
cp /home/git/knotserver.db* /home/git/database
451
+
```
452
+
453
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
454
+
the new file path (_not_ the directory):
455
+
456
+
```
457
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
458
+
```
459
+
460
+
#### Repositories
461
+
462
+
As an example, let's say the repositories are currently in `/home/git`, and we
463
+
want to move them into `/home/git/repositories`.
464
+
465
+
Create the new folder, then move the existing repositories (if there are any):
466
+
467
+
```
468
+
mkdir /home/git/repositories
469
+
# move all DIDs into the new folder; these will vary for you!
470
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
471
+
```
472
+
473
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
474
+
to the new directory:
475
+
476
+
```
477
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
478
+
```
479
+
480
+
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
481
+
repository path:
482
+
483
+
```
484
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
485
+
Match User git
486
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
487
+
AuthorizedKeysCommandUser nobody
488
+
EOF
489
+
```
490
+
491
+
Make sure to restart your SSH server!
492
+
493
+
#### MOTD (message of the day)
494
+
495
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
496
+
`/home/git/motd` file:
497
+
498
+
```
499
+
printf "Hi from this knot!\n" > /home/git/motd
500
+
```
501
+
502
+
Note that you should add a newline at the end if setting a non-empty message
503
+
since the knot won't do this for you.
504
+
505
+
# Spindles
506
+
507
+
## Pipelines
508
+
509
+
Spindle workflows allow you to write CI/CD pipelines in a
510
+
simple format. They're located in the `.tangled/workflows`
511
+
directory at the root of your repository, and are defined
512
+
using YAML.
513
+
514
+
The fields are:
515
+
516
+
- [Trigger](#trigger): A **required** field that defines
517
+
when a workflow should be triggered.
518
+
- [Engine](#engine): A **required** field that defines which
519
+
engine a workflow should run on.
520
+
- [Clone options](#clone-options): An **optional** field
521
+
that defines how the repository should be cloned.
522
+
- [Dependencies](#dependencies): An **optional** field that
523
+
allows you to list dependencies you may need.
524
+
- [Environment](#environment): An **optional** field that
525
+
allows you to define environment variables.
526
+
- [Steps](#steps): An **optional** field that allows you to
527
+
define what steps should run in the workflow.
528
+
529
+
### Trigger
530
+
531
+
The first thing to add to a workflow is the trigger, which
532
+
defines when a workflow runs. This is defined using a `when`
533
+
field, which takes in a list of conditions. Each condition
534
+
has the following fields:
535
+
536
+
- `event`: This is a **required** field that defines when
537
+
your workflow should run. It's a list that can take one or
538
+
more of the following values:
539
+
- `push`: The workflow should run every time a commit is
540
+
pushed to the repository.
541
+
- `pull_request`: The workflow should run every time a
542
+
pull request is made or updated.
543
+
- `manual`: The workflow can be triggered manually.
544
+
- `branch`: Defines which branches the workflow should run
545
+
for. If used with the `push` event, commits to the
546
+
branch(es) listed here will trigger the workflow. If used
547
+
with the `pull_request` event, updates to pull requests
548
+
targeting the branch(es) listed here will trigger the
549
+
workflow. This field has no effect with the `manual`
550
+
event. Supports glob patterns using `*` and `**` (e.g.,
551
+
`main`, `develop`, `release-*`). Either `branch` or `tag`
552
+
(or both) must be specified for `push` events.
553
+
- `tag`: Defines which tags the workflow should run for.
554
+
Only used with the `push` event - when tags matching the
555
+
pattern(s) listed here are pushed, the workflow will
556
+
trigger. This field has no effect with `pull_request` or
557
+
`manual` events. Supports glob patterns using `*` and `**`
558
+
(e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
559
+
`tag` (or both) must be specified for `push` events.
560
+
561
+
For example, if you'd like to define a workflow that runs
562
+
when commits are pushed to the `main` and `develop`
563
+
branches, or when pull requests that target the `main`
564
+
branch are updated, or manually, you can do so with:
565
+
566
+
```yaml
567
+
when:
568
+
- event: ["push", "manual"]
569
+
branch: ["main", "develop"]
570
+
- event: ["pull_request"]
571
+
branch: ["main"]
572
+
```
573
+
574
+
You can also trigger workflows on tag pushes. For instance,
575
+
to run a deployment workflow when tags matching `v*` are
576
+
pushed:
577
+
578
+
```yaml
579
+
when:
580
+
- event: ["push"]
581
+
tag: ["v*"]
582
+
```
583
+
584
+
You can even combine branch and tag patterns in a single
585
+
constraint (the workflow triggers if either matches):
586
+
587
+
```yaml
588
+
when:
589
+
- event: ["push"]
590
+
branch: ["main", "release-*"]
591
+
tag: ["v*", "stable"]
592
+
```
593
+
594
+
### Engine
595
+
596
+
Next is the engine on which the workflow should run, defined
597
+
using the **required** `engine` field. The currently
598
+
supported engines are:
599
+
600
+
- `nixery`: This uses an instance of
601
+
[Nixery](https://nixery.dev) to run steps, which allows
602
+
you to add [dependencies](#dependencies) from
603
+
Nixpkgs (https://github.com/NixOS/nixpkgs). You can
604
+
search for packages on https://search.nixos.org, and
605
+
there's a pretty good chance the package(s) you're looking
606
+
for will be there.
607
+
608
+
Example:
609
+
610
+
```yaml
611
+
engine: "nixery"
612
+
```
613
+
614
+
### Clone options
615
+
616
+
When a workflow starts, the first step is to clone the
617
+
repository. You can customize this behavior using the
618
+
**optional** `clone` field. It has the following fields:
619
+
620
+
- `skip`: Setting this to `true` will skip cloning the
621
+
repository. This can be useful if your workflow is doing
622
+
something that doesn't require anything from the
623
+
repository itself. This is `false` by default.
624
+
- `depth`: This sets the number of commits, or the "clone
625
+
depth", to fetch from the repository. For example, if you
626
+
set this to 2, the last 2 commits will be fetched. By
627
+
default, the depth is set to 1, meaning only the most
628
+
recent commit will be fetched, which is the commit that
629
+
triggered the workflow.
630
+
- `submodules`: If you use Git submodules
631
+
(https://git-scm.com/book/en/v2/Git-Tools-Submodules)
632
+
in your repository, setting this field to `true` will
633
+
recursively fetch all submodules. This is `false` by
634
+
default.
635
+
636
+
The default settings are:
637
+
638
+
```yaml
639
+
clone:
640
+
skip: false
641
+
depth: 1
642
+
submodules: false
643
+
```
644
+
645
+
### Dependencies
646
+
647
+
Usually when you're running a workflow, you'll need
648
+
additional dependencies. The `dependencies` field lets you
649
+
define which dependencies to get, and from where. It's a
650
+
key-value map, with the key being the registry to fetch
651
+
dependencies from, and the value being the list of
652
+
dependencies to fetch.
653
+
654
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a
655
+
package called `my_pkg` you've made from your own registry
656
+
at your repository at
657
+
`https://tangled.org/@example.com/my_pkg`. You can define
658
+
those dependencies like so:
659
+
660
+
```yaml
661
+
dependencies:
662
+
# nixpkgs
663
+
nixpkgs:
664
+
- nodejs
665
+
- go
666
+
# custom registry
667
+
git+https://tangled.org/@example.com/my_pkg:
668
+
- my_pkg
669
+
```
670
+
671
+
Now these dependencies are available to use in your
672
+
workflow!
673
+
674
+
### Environment
675
+
676
+
The `environment` field allows you define environment
677
+
variables that will be available throughout the entire
678
+
workflow. **Do not put secrets here, these environment
679
+
variables are visible to anyone viewing the repository. You
680
+
can add secrets for pipelines in your repository's
681
+
settings.**
682
+
683
+
Example:
684
+
685
+
```yaml
686
+
environment:
687
+
GOOS: "linux"
688
+
GOARCH: "arm64"
689
+
NODE_ENV: "production"
690
+
MY_ENV_VAR: "MY_ENV_VALUE"
691
+
```
692
+
693
+
### Steps
694
+
695
+
The `steps` field allows you to define what steps should run
696
+
in the workflow. It's a list of step objects, each with the
697
+
following fields:
698
+
699
+
- `name`: This field allows you to give your step a name.
700
+
This name is visible in your workflow runs, and is used to
701
+
describe what the step is doing.
702
+
- `command`: This field allows you to define a command to
703
+
run in that step. The step is run in a Bash shell, and the
704
+
logs from the command will be visible in the pipelines
705
+
page on the Tangled website. The
706
+
[dependencies](#dependencies) you added will be available
707
+
to use here.
708
+
- `environment`: Similar to the global
709
+
[environment](#environment) config, this **optional**
710
+
field is a key-value map that allows you to set
711
+
environment variables for the step. **Do not put secrets
712
+
here, these environment variables are visible to anyone
713
+
viewing the repository. You can add secrets for pipelines
714
+
in your repository's settings.**
715
+
716
+
Example:
717
+
718
+
```yaml
719
+
steps:
720
+
- name: "Build backend"
721
+
command: "go build"
722
+
environment:
723
+
GOOS: "darwin"
724
+
GOARCH: "arm64"
725
+
- name: "Build frontend"
726
+
command: "npm run build"
727
+
environment:
728
+
NODE_ENV: "production"
729
+
```
730
+
731
+
### Complete workflow
732
+
733
+
```yaml
734
+
# .tangled/workflows/build.yml
735
+
736
+
when:
737
+
- event: ["push", "manual"]
738
+
branch: ["main", "develop"]
739
+
- event: ["pull_request"]
740
+
branch: ["main"]
741
+
742
+
engine: "nixery"
743
+
744
+
# using the default values
745
+
clone:
746
+
skip: false
747
+
depth: 1
748
+
submodules: false
749
+
750
+
dependencies:
751
+
# nixpkgs
752
+
nixpkgs:
753
+
- nodejs
754
+
- go
755
+
# custom registry
756
+
git+https://tangled.org/@example.com/my_pkg:
757
+
- my_pkg
758
+
759
+
environment:
760
+
GOOS: "linux"
761
+
GOARCH: "arm64"
762
+
NODE_ENV: "production"
763
+
MY_ENV_VAR: "MY_ENV_VALUE"
764
+
765
+
steps:
766
+
- name: "Build backend"
767
+
command: "go build"
768
+
environment:
769
+
GOOS: "darwin"
770
+
GOARCH: "arm64"
771
+
- name: "Build frontend"
772
+
command: "npm run build"
773
+
environment:
774
+
NODE_ENV: "production"
775
+
```
776
+
777
+
If you want another example of a workflow, you can look at
778
+
the one [Tangled uses to build the
779
+
project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
780
+
781
+
## Self-hosting guide
782
+
783
+
### Prerequisites
784
+
785
+
* Go
786
+
* Docker (the only supported backend currently)
787
+
788
+
### Configuration
789
+
790
+
Spindle is configured using environment variables. The following environment variables are available:
791
+
792
+
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
793
+
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
794
+
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
795
+
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
796
+
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
797
+
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
798
+
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
799
+
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
800
+
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
801
+
802
+
### Running spindle
803
+
804
+
1. **Set the environment variables.** For example:
805
+
806
+
```shell
807
+
export SPINDLE_SERVER_HOSTNAME="your-hostname"
808
+
export SPINDLE_SERVER_OWNER="your-did"
809
+
```
810
+
811
+
2. **Build the Spindle binary.**
812
+
813
+
```shell
814
+
cd core
815
+
go mod download
816
+
go build -o cmd/spindle/spindle cmd/spindle/main.go
817
+
```
818
+
819
+
3. **Create the log directory.**
820
+
821
+
```shell
822
+
sudo mkdir -p /var/log/spindle
823
+
sudo chown $USER:$USER -R /var/log/spindle
824
+
```
825
+
826
+
4. **Run the Spindle binary.**
827
+
828
+
```shell
829
+
./cmd/spindle/spindle
830
+
```
831
+
832
+
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
833
+
834
+
## Architecture
835
+
836
+
Spindle is a small CI runner service. Here's a high-level overview of how it operates:
837
+
838
+
* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
839
+
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
840
+
* When a new repo record comes through (typically when you add a spindle to a
841
+
repo from the settings), spindle then resolves the underlying knot and
842
+
subscribes to repo events (see:
843
+
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
844
+
* The spindle engine then handles execution of the pipeline, with results and
845
+
logs beamed on the spindle event stream over WebSocket
846
+
847
+
### The engine
848
+
849
+
At present, the only supported backend is Docker (and Podman, if Docker
850
+
compatibility is enabled, so that `/run/docker.sock` is created). spindle
851
+
executes each step in the pipeline in a fresh container, with state persisted
852
+
across steps within the `/tangled/workspace` directory.
853
+
854
+
The base image for the container is constructed on the fly using
855
+
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
856
+
used packages.
857
+
858
+
The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
859
+
860
+
## Secrets with openbao
861
+
862
+
This document covers setting up spindle to use OpenBao for secrets
863
+
management via OpenBao Proxy instead of the default SQLite backend.
864
+
865
+
### Overview
866
+
867
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
868
+
authentication automatically using AppRole credentials, while spindle
869
+
connects to the local proxy instead of directly to the OpenBao server.
870
+
871
+
This approach provides better security, automatic token renewal, and
872
+
simplified application code.
873
+
874
+
### Installation
875
+
876
+
Install OpenBao from Nixpkgs:
877
+
878
+
```bash
879
+
nix shell nixpkgs#openbao # for a local server
880
+
```
881
+
882
+
### Setup
883
+
884
+
The setup process can is documented for both local development and production.
885
+
886
+
#### Local development
887
+
888
+
Start OpenBao in dev mode:
889
+
890
+
```bash
891
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
892
+
```
893
+
894
+
This starts OpenBao on `http://localhost:8201` with a root token.
895
+
896
+
Set up environment for bao CLI:
897
+
898
+
```bash
899
+
export BAO_ADDR=http://localhost:8200
900
+
export BAO_TOKEN=root
901
+
```
902
+
903
+
#### Production
904
+
905
+
You would typically use a systemd service with a
906
+
configuration file. Refer to
907
+
[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
908
+
for how this can be achieved using Nix.
909
+
910
+
Then, initialize the bao server:
911
+
912
+
```bash
913
+
bao operator init -key-shares=1 -key-threshold=1
914
+
```
915
+
916
+
This will print out an unseal key and a root key. Save them
917
+
somewhere (like a password manager). Then unseal the vault
918
+
to begin setting it up:
919
+
920
+
```bash
921
+
bao operator unseal <unseal_key>
922
+
```
923
+
924
+
All steps below remain the same across both dev and
925
+
production setups.
926
+
927
+
#### Configure openbao server
928
+
929
+
Create the spindle KV mount:
930
+
931
+
```bash
932
+
bao secrets enable -path=spindle -version=2 kv
933
+
```
934
+
935
+
Set up AppRole authentication and policy:
936
+
937
+
Create a policy file `spindle-policy.hcl`:
938
+
939
+
```hcl
940
+
# Full access to spindle KV v2 data
941
+
path "spindle/data/*" {
942
+
capabilities = ["create", "read", "update", "delete"]
943
+
}
944
+
945
+
# Access to metadata for listing and management
946
+
path "spindle/metadata/*" {
947
+
capabilities = ["list", "read", "delete", "update"]
948
+
}
949
+
950
+
# Allow listing at root level
951
+
path "spindle/" {
952
+
capabilities = ["list"]
953
+
}
954
+
955
+
# Required for connection testing and health checks
956
+
path "auth/token/lookup-self" {
957
+
capabilities = ["read"]
958
+
}
959
+
```
960
+
961
+
Apply the policy and create an AppRole:
962
+
963
+
```bash
964
+
bao policy write spindle-policy spindle-policy.hcl
965
+
bao auth enable approle
966
+
bao write auth/approle/role/spindle \
967
+
token_policies="spindle-policy" \
968
+
token_ttl=1h \
969
+
token_max_ttl=4h \
970
+
bind_secret_id=true \
971
+
secret_id_ttl=0 \
972
+
secret_id_num_uses=0
973
+
```
974
+
975
+
Get the credentials:
976
+
977
+
```bash
978
+
# Get role ID (static)
979
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
980
+
981
+
# Generate secret ID
982
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
983
+
984
+
echo "Role ID: $ROLE_ID"
985
+
echo "Secret ID: $SECRET_ID"
986
+
```
987
+
988
+
#### Create proxy configuration
989
+
990
+
Create the credential files:
991
+
992
+
```bash
993
+
# Create directory for OpenBao files
994
+
mkdir -p /tmp/openbao
995
+
996
+
# Save credentials
997
+
echo "$ROLE_ID" > /tmp/openbao/role-id
998
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
999
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
1000
+
```
1001
+
1002
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
1003
+
1004
+
```hcl
1005
+
# OpenBao server connection
1006
+
vault {
1007
+
address = "http://localhost:8200"
1008
+
}
1009
+
1010
+
# Auto-Auth using AppRole
1011
+
auto_auth {
1012
+
method "approle" {
1013
+
mount_path = "auth/approle"
1014
+
config = {
1015
+
role_id_file_path = "/tmp/openbao/role-id"
1016
+
secret_id_file_path = "/tmp/openbao/secret-id"
1017
+
}
1018
+
}
1019
+
1020
+
# Optional: write token to file for debugging
1021
+
sink "file" {
1022
+
config = {
1023
+
path = "/tmp/openbao/token"
1024
+
mode = 0640
1025
+
}
1026
+
}
1027
+
}
1028
+
1029
+
# Proxy listener for spindle
1030
+
listener "tcp" {
1031
+
address = "127.0.0.1:8201"
1032
+
tls_disable = true
1033
+
}
1034
+
1035
+
# Enable API proxy with auto-auth token
1036
+
api_proxy {
1037
+
use_auto_auth_token = true
1038
+
}
1039
+
1040
+
# Enable response caching
1041
+
cache {
1042
+
use_auto_auth_token = true
1043
+
}
1044
+
1045
+
# Logging
1046
+
log_level = "info"
1047
+
```
1048
+
1049
+
#### Start the proxy
1050
+
1051
+
Start OpenBao Proxy:
1052
+
1053
+
```bash
1054
+
bao proxy -config=/tmp/openbao/proxy.hcl
1055
+
```
1056
+
1057
+
The proxy will authenticate with OpenBao and start listening on
1058
+
`127.0.0.1:8201`.
1059
+
1060
+
#### Configure spindle
1061
+
1062
+
Set these environment variables for spindle:
1063
+
1064
+
```bash
1065
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
1066
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
1067
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1068
+
```
1069
+
1070
+
On startup, spindle will now connect to the local proxy,
1071
+
which handles all authentication automatically.
1072
+
1073
+
### Production setup for proxy
1074
+
1075
+
For production, you'll want to run the proxy as a service:
1076
+
1077
+
Place your production configuration in
1078
+
`/etc/openbao/proxy.hcl` with proper TLS settings for the
1079
+
vault connection.
1080
+
1081
+
### Verifying setup
1082
+
1083
+
Test the proxy directly:
1084
+
1085
+
```bash
1086
+
# Check proxy health
1087
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
1088
+
1089
+
# Test token lookup through proxy
1090
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
1091
+
```
1092
+
1093
+
Test OpenBao operations through the server:
1094
+
1095
+
```bash
1096
+
# List all secrets
1097
+
bao kv list spindle/
1098
+
1099
+
# Add a test secret via the spindle API, then check it exists
1100
+
bao kv list spindle/repos/
1101
+
1102
+
# Get a specific secret
1103
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
1104
+
```
1105
+
1106
+
### How it works
1107
+
1108
+
- Spindle connects to OpenBao Proxy on localhost (typically
1109
+
port 8200 or 8201)
1110
+
- The proxy authenticates with OpenBao using AppRole
1111
+
credentials
1112
+
- All spindle requests go through the proxy, which injects
1113
+
authentication tokens
1114
+
- Secrets are stored at
1115
+
`spindle/repos/{sanitized_repo_path}/{secret_key}`
1116
+
- Repository paths like `did:plc:alice/myrepo` become
1117
+
`did_plc_alice_myrepo`
1118
+
- The proxy handles all token renewal automatically
1119
+
- Spindle no longer manages tokens or authentication
1120
+
directly
1121
+
1122
+
### Troubleshooting
1123
+
1124
+
**Connection refused**: Check that the OpenBao Proxy is
1125
+
running and listening on the configured address.
1126
+
1127
+
**403 errors**: Verify the AppRole credentials are correct
1128
+
and the policy has the necessary permissions.
1129
+
1130
+
**404 route errors**: The spindle KV mount probably doesn't
1131
+
existโrun the mount creation step again.
1132
+
1133
+
**Proxy authentication failures**: Check the proxy logs and
1134
+
verify the role-id and secret-id files are readable and
1135
+
contain valid credentials.
1136
+
1137
+
**Secret not found after writing**: This can indicate policy
1138
+
permission issues. Verify the policy includes both
1139
+
`spindle/data/*` and `spindle/metadata/*` paths with
1140
+
appropriate capabilities.
1141
+
1142
+
Check proxy logs:
1143
+
1144
+
```bash
1145
+
# If running as systemd service
1146
+
journalctl -u openbao-proxy -f
1147
+
1148
+
# If running directly, check the console output
1149
+
```
1150
+
1151
+
Test AppRole authentication manually:
1152
+
1153
+
```bash
1154
+
bao write auth/approle/login \
1155
+
role_id="$(cat /tmp/openbao/role-id)" \
1156
+
secret_id="$(cat /tmp/openbao/secret-id)"
1157
+
```
1158
+
1159
+
# Migrating knots and spindles
1160
+
1161
+
Sometimes, non-backwards compatible changes are made to the
1162
+
knot/spindle XRPC APIs. If you host a knot or a spindle, you
1163
+
will need to follow this guide to upgrade. Typically, this
1164
+
only requires you to deploy the newest version.
1165
+
1166
+
This document is laid out in reverse-chronological order.
1167
+
Newer migration guides are listed first, and older guides
1168
+
are further down the page.
1169
+
1170
+
## Upgrading from v1.8.x
1171
+
1172
+
After v1.8.2, the HTTP API for knots and spindles has been
1173
+
deprecated and replaced with XRPC. Repositories on outdated
1174
+
knots will not be viewable from the appview. Upgrading is
1175
+
straightforward however.
1176
+
1177
+
For knots:
1178
+
1179
+
- Upgrade to the latest tag (v1.9.0 or above)
1180
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1181
+
hit the "retry" button to verify your knot
1182
+
1183
+
For spindles:
1184
+
1185
+
- Upgrade to the latest tag (v1.9.0 or above)
1186
+
- Head to the [spindle
1187
+
dashboard](https://tangled.org/settings/spindles) and hit the
1188
+
"retry" button to verify your spindle
1189
+
1190
+
## Upgrading from v1.7.x
1191
+
1192
+
After v1.7.0, knot secrets have been deprecated. You no
1193
+
longer need a secret from the appview to run a knot. All
1194
+
authorized commands to knots are managed via [Inter-Service
1195
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
1196
+
Knots will be read-only until upgraded.
1197
+
1198
+
Upgrading is quite easy, in essence:
1199
+
1200
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
1201
+
environment variable entirely
1202
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
1203
+
your DID. You can find your DID in the
1204
+
[settings](https://tangled.org/settings) page.
1205
+
- Restart your knot once you have replaced the environment
1206
+
variable
1207
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1208
+
hit the "retry" button to verify your knot. This simply
1209
+
writes a `sh.tangled.knot` record to your PDS.
1210
+
1211
+
If you use the nix module, simply bump the flake to the
1212
+
latest revision, and change your config block like so:
1213
+
1214
+
```diff
1215
+
services.tangled.knot = {
1216
+
enable = true;
1217
+
server = {
1218
+
- secretFile = /path/to/secret;
1219
+
+ owner = "did:plc:foo";
1220
+
};
1221
+
};
1222
+
```
1223
+
1224
+
# Hacking on Tangled
1225
+
1226
+
We highly recommend [installing
1227
+
Nix](https://nixos.org/download/) (the package manager)
1228
+
before working on the codebase. The Nix flake provides a lot
1229
+
of helpers to get started and most importantly, builds and
1230
+
dev shells are entirely deterministic.
1231
+
1232
+
To set up your dev environment:
1233
+
1234
+
```bash
1235
+
nix develop
1236
+
```
1237
+
1238
+
Non-Nix users can look at the `devShell` attribute in the
1239
+
`flake.nix` file to determine necessary dependencies.
1240
+
1241
+
## Running the appview
1242
+
1243
+
The Nix flake also exposes a few `app` attributes (run `nix
1244
+
flake show` to see a full list of what the flake provides),
1245
+
one of the apps runs the appview with the `air`
1246
+
live-reloader:
1247
+
1248
+
```bash
1249
+
TANGLED_DEV=true nix run .#watch-appview
1250
+
1251
+
# TANGLED_DB_PATH might be of interest to point to
1252
+
# different sqlite DBs
1253
+
1254
+
# in a separate shell, you can live-reload tailwind
1255
+
nix run .#watch-tailwind
1256
+
```
1257
+
1258
+
To authenticate with the appview, you will need Redis and
1259
+
OAuth JWKs to be set up:
1260
+
1261
+
```
1262
+
# OAuth JWKs should already be set up by the Nix devshell:
1263
+
echo $TANGLED_OAUTH_CLIENT_SECRET
1264
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1265
+
1266
+
echo $TANGLED_OAUTH_CLIENT_KID
1267
+
1761667908
1268
+
1269
+
# if not, you can set it up yourself:
1270
+
goat key generate -t P-256
1271
+
Key Type: P-256 / secp256r1 / ES256 private key
1272
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
1273
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
1274
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
1275
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
1276
+
1277
+
# the secret key from above
1278
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1279
+
1280
+
# Run Redis in a new shell to store OAuth sessions
1281
+
redis-server
1282
+
```
1283
+
1284
+
## Running knots and spindles
1285
+
1286
+
An end-to-end knot setup requires setting up a machine with
1287
+
`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1288
+
quite cumbersome. So the Nix flake provides a
1289
+
`nixosConfiguration` to do so.
1290
+
1291
+
<details>
1292
+
<summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1293
+
1294
+
In order to build Tangled's dev VM on macOS, you will
1295
+
first need to set up a Linux Nix builder. The recommended
1296
+
way to do so is to run a [`darwin.linux-builder`
1297
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
1298
+
and to register it in `nix.conf` as a builder for Linux
1299
+
with the same architecture as your Mac (`linux-aarch64` if
1300
+
you are using Apple Silicon).
1301
+
1302
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1303
+
> the Tangled repo so that it doesn't conflict with the other VM. For example,
1304
+
> you can do
1305
+
>
1306
+
> ```shell
1307
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
1308
+
> ```
1309
+
>
1310
+
> to store the builder VM in a temporary dir.
1311
+
>
1312
+
> You should read and follow [all the other intructions][darwin builder vm] to
1313
+
> avoid subtle problems.
1314
+
1315
+
Alternatively, you can use any other method to set up a
1316
+
Linux machine with Nix installed that you can `sudo ssh`
1317
+
into (in other words, root user on your Mac has to be able
1318
+
to ssh into the Linux machine without entering a password)
1319
+
and that has the same architecture as your Mac. See
1320
+
[remote builder
1321
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
1322
+
for how to register such a builder in `nix.conf`.
1323
+
1324
+
> WARNING: If you'd like to use
1325
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
1326
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
1327
+
> ssh` works can be tricky. It seems to be [possible with
1328
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1329
+
1330
+
</details>
1331
+
1332
+
To begin, grab your DID from http://localhost:3000/settings.
1333
+
Then, set `TANGLED_VM_KNOT_OWNER` and
1334
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
1335
+
lightweight NixOS VM like so:
1336
+
1337
+
```bash
1338
+
nix run --impure .#vm
1339
+
1340
+
# type `poweroff` at the shell to exit the VM
1341
+
```
1342
+
1343
+
This starts a knot on port 6444, a spindle on port 6555
1344
+
with `ssh` exposed on port 2222.
1345
+
1346
+
Once the services are running, head to
1347
+
http://localhost:3000/settings/knots and hit "Verify". It should
1348
+
verify the ownership of the services instantly if everything
1349
+
went smoothly.
1350
+
1351
+
You can push repositories to this VM with this ssh config
1352
+
block on your main machine:
1353
+
1354
+
```bash
1355
+
Host nixos-shell
1356
+
Hostname localhost
1357
+
Port 2222
1358
+
User git
1359
+
IdentityFile ~/.ssh/my_tangled_key
1360
+
```
1361
+
1362
+
Set up a remote called `local-dev` on a git repo:
1363
+
1364
+
```bash
1365
+
git remote add local-dev git@nixos-shell:user/repo
1366
+
git push local-dev main
1367
+
```
1368
+
1369
+
The above VM should already be running a spindle on
1370
+
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1371
+
hit "Verify". You can then configure each repository to use
1372
+
this spindle and run CI jobs.
1373
+
1374
+
Of interest when debugging spindles:
1375
+
1376
+
```
1377
+
# Service logs from journald:
1378
+
journalctl -xeu spindle
1379
+
1380
+
# CI job logs from disk:
1381
+
ls /var/log/spindle
1382
+
1383
+
# Debugging spindle database:
1384
+
sqlite3 /var/lib/spindle/spindle.db
1385
+
1386
+
# litecli has a nicer REPL interface:
1387
+
litecli /var/lib/spindle/spindle.db
1388
+
```
1389
+
1390
+
If for any reason you wish to disable either one of the
1391
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
1392
+
`services.tangled.spindle.enable` (or
1393
+
`services.tangled.knot.enable`) to `false`.
1394
+
1395
+
# Contribution guide
1396
+
1397
+
## Commit guidelines
1398
+
1399
+
We follow a commit style similar to the Go project. Please keep commits:
1400
+
1401
+
* **atomic**: each commit should represent one logical change
1402
+
* **descriptive**: the commit message should clearly describe what the
1403
+
change does and why it's needed
1404
+
1405
+
### Message format
1406
+
1407
+
```
1408
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
1409
+
1410
+
Optional longer description can go here, if necessary. Explain what the
1411
+
change does and why, especially if not obvious. Reference relevant
1412
+
issues or PRs when applicable. These can be links for now since we don't
1413
+
auto-link issues/PRs yet.
1414
+
```
1415
+
1416
+
Here are some examples:
1417
+
1418
+
```
1419
+
appview/state: fix token expiry check in middleware
1420
+
1421
+
The previous check did not account for clock drift, leading to premature
1422
+
token invalidation.
1423
+
```
1424
+
1425
+
```
1426
+
knotserver/git/service: improve error checking in upload-pack
1427
+
```
1428
+
1429
+
1430
+
### General notes
1431
+
1432
+
- PRs get merged "as-is" (fast-forward)โlike applying a patch-series
1433
+
using `git am`. At present, there is no squashingโso please author
1434
+
your commits as they would appear on `master`, following the above
1435
+
guidelines.
1436
+
- If there is a lot of nesting, for example "appview:
1437
+
pages/templates/repo/fragments: ...", these can be truncated down to
1438
+
just "appview: repo/fragments: ...". If the change affects a lot of
1439
+
subdirectories, you may abbreviate to just the top-level names, e.g.
1440
+
"appview: ..." or "knotserver: ...".
1441
+
- Keep commits lowercased with no trailing period.
1442
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
1443
+
"fixed bug" or "fixes bug").
1444
+
- Try to keep the summary line under 72 characters, but we aren't too
1445
+
fussed about this.
1446
+
- Follow the same formatting for PR titles if filled manually.
1447
+
- Don't include unrelated changes in the same commit.
1448
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
1449
+
before submitting if necessary.
1450
+
1451
+
## Code formatting
1452
+
1453
+
We use a variety of tools to format our code, and multiplex them with
1454
+
[`treefmt`](https://treefmt.com). All you need to do to format your changes
1455
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1456
+
1457
+
## Proposals for bigger changes
1458
+
1459
+
Small fixes like typos, minor bugs, or trivial refactors can be
1460
+
submitted directly as PRs.
1461
+
1462
+
For larger changesโespecially those introducing new features, significant
1463
+
refactoring, or altering system behaviorโplease open a proposal first. This
1464
+
helps us evaluate the scope, design, and potential impact before implementation.
1465
+
1466
+
Create a new issue titled:
1467
+
1468
+
```
1469
+
proposal: <affected scope>: <summary of change>
1470
+
```
1471
+
1472
+
In the description, explain:
1473
+
1474
+
- What the change is
1475
+
- Why it's needed
1476
+
- How you plan to implement it (roughly)
1477
+
- Any open questions or tradeoffs
1478
+
1479
+
We'll use the issue thread to discuss and refine the idea before moving
1480
+
forward.
1481
+
1482
+
## Developer Certificate of Origin (DCO)
1483
+
1484
+
We require all contributors to certify that they have the right to
1485
+
submit the code they're contributing. To do this, we follow the
1486
+
[Developer Certificate of Origin
1487
+
(DCO)](https://developercertificate.org/).
1488
+
1489
+
By signing your commits, you're stating that the contribution is your
1490
+
own work, or that you have the right to submit it under the project's
1491
+
license. This helps us keep things clean and legally sound.
1492
+
1493
+
To sign your commit, just add the `-s` flag when committing:
1494
+
1495
+
```sh
1496
+
git commit -s -m "your commit message"
1497
+
```
1498
+
1499
+
This appends a line like:
1500
+
1501
+
```
1502
+
Signed-off-by: Your Name <your.email@example.com>
1503
+
```
1504
+
1505
+
We won't merge commits if they aren't signed off. If you forget, you can
1506
+
amend the last commit like this:
1507
+
1508
+
```sh
1509
+
git commit --amend -s
1510
+
```
1511
+
1512
+
If you're submitting a PR with multiple commits, make sure each one is
1513
+
signed.
1514
+
1515
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
1516
+
to make it sign off commits in the tangled repo:
1517
+
1518
+
```shell
1519
+
# Safety check, should say "No matching config key..."
1520
+
jj config list templates.commit_trailers
1521
+
# The command below may need to be adjusted if the command above returned something.
1522
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
1523
+
```
1524
+
1525
+
Refer to the [jujutsu
1526
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1527
+
for more information.
-136
docs/contributing.md
-136
docs/contributing.md
···
1
-
# tangled contributing guide
2
-
3
-
## commit guidelines
4
-
5
-
We follow a commit style similar to the Go project. Please keep commits:
6
-
7
-
* **atomic**: each commit should represent one logical change
8
-
* **descriptive**: the commit message should clearly describe what the
9
-
change does and why it's needed
10
-
11
-
### message format
12
-
13
-
```
14
-
<service/top-level directory>/<affected package/directory>: <short summary of change>
15
-
16
-
17
-
Optional longer description can go here, if necessary. Explain what the
18
-
change does and why, especially if not obvious. Reference relevant
19
-
issues or PRs when applicable. These can be links for now since we don't
20
-
auto-link issues/PRs yet.
21
-
```
22
-
23
-
Here are some examples:
24
-
25
-
```
26
-
appview/state: fix token expiry check in middleware
27
-
28
-
The previous check did not account for clock drift, leading to premature
29
-
token invalidation.
30
-
```
31
-
32
-
```
33
-
knotserver/git/service: improve error checking in upload-pack
34
-
```
35
-
36
-
37
-
### general notes
38
-
39
-
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
40
-
using `git am`. At present, there is no squashing -- so please author
41
-
your commits as they would appear on `master`, following the above
42
-
guidelines.
43
-
- If there is a lot of nesting, for example "appview:
44
-
pages/templates/repo/fragments: ...", these can be truncated down to
45
-
just "appview: repo/fragments: ...". If the change affects a lot of
46
-
subdirectories, you may abbreviate to just the top-level names, e.g.
47
-
"appview: ..." or "knotserver: ...".
48
-
- Keep commits lowercased with no trailing period.
49
-
- Use the imperative mood in the summary line (e.g., "fix bug" not
50
-
"fixed bug" or "fixes bug").
51
-
- Try to keep the summary line under 72 characters, but we aren't too
52
-
fussed about this.
53
-
- Follow the same formatting for PR titles if filled manually.
54
-
- Don't include unrelated changes in the same commit.
55
-
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
56
-
before submitting if necessary.
57
-
58
-
## code formatting
59
-
60
-
We use a variety of tools to format our code, and multiplex them with
61
-
[`treefmt`](https://treefmt.com): all you need to do to format your changes
62
-
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
63
-
64
-
## proposals for bigger changes
65
-
66
-
Small fixes like typos, minor bugs, or trivial refactors can be
67
-
submitted directly as PRs.
68
-
69
-
For larger changesโespecially those introducing new features, significant
70
-
refactoring, or altering system behaviorโplease open a proposal first. This
71
-
helps us evaluate the scope, design, and potential impact before implementation.
72
-
73
-
### proposal format
74
-
75
-
Create a new issue titled:
76
-
77
-
```
78
-
proposal: <affected scope>: <summary of change>
79
-
```
80
-
81
-
In the description, explain:
82
-
83
-
- What the change is
84
-
- Why it's needed
85
-
- How you plan to implement it (roughly)
86
-
- Any open questions or tradeoffs
87
-
88
-
We'll use the issue thread to discuss and refine the idea before moving
89
-
forward.
90
-
91
-
## developer certificate of origin (DCO)
92
-
93
-
We require all contributors to certify that they have the right to
94
-
submit the code they're contributing. To do this, we follow the
95
-
[Developer Certificate of Origin
96
-
(DCO)](https://developercertificate.org/).
97
-
98
-
By signing your commits, you're stating that the contribution is your
99
-
own work, or that you have the right to submit it under the project's
100
-
license. This helps us keep things clean and legally sound.
101
-
102
-
To sign your commit, just add the `-s` flag when committing:
103
-
104
-
```sh
105
-
git commit -s -m "your commit message"
106
-
```
107
-
108
-
This appends a line like:
109
-
110
-
```
111
-
Signed-off-by: Your Name <your.email@example.com>
112
-
```
113
-
114
-
We won't merge commits if they aren't signed off. If you forget, you can
115
-
amend the last commit like this:
116
-
117
-
```sh
118
-
git commit --amend -s
119
-
```
120
-
121
-
If you're submitting a PR with multiple commits, make sure each one is
122
-
signed.
123
-
124
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
125
-
to make it sign off commits in the tangled repo:
126
-
127
-
```shell
128
-
# Safety check, should say "No matching config key..."
129
-
jj config list templates.commit_trailers
130
-
# The command below may need to be adjusted if the command above returned something.
131
-
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
132
-
```
133
-
134
-
Refer to the [jj
135
-
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
136
-
for more information.
-172
docs/hacking.md
-172
docs/hacking.md
···
1
-
# hacking on tangled
2
-
3
-
We highly recommend [installing
4
-
nix](https://nixos.org/download/) (the package manager)
5
-
before working on the codebase. The nix flake provides a lot
6
-
of helpers to get started and most importantly, builds and
7
-
dev shells are entirely deterministic.
8
-
9
-
To set up your dev environment:
10
-
11
-
```bash
12
-
nix develop
13
-
```
14
-
15
-
Non-nix users can look at the `devShell` attribute in the
16
-
`flake.nix` file to determine necessary dependencies.
17
-
18
-
## running the appview
19
-
20
-
The nix flake also exposes a few `app` attributes (run `nix
21
-
flake show` to see a full list of what the flake provides),
22
-
one of the apps runs the appview with the `air`
23
-
live-reloader:
24
-
25
-
```bash
26
-
TANGLED_DEV=true nix run .#watch-appview
27
-
28
-
# TANGLED_DB_PATH might be of interest to point to
29
-
# different sqlite DBs
30
-
31
-
# in a separate shell, you can live-reload tailwind
32
-
nix run .#watch-tailwind
33
-
```
34
-
35
-
To authenticate with the appview, you will need redis and
36
-
OAUTH JWKs to be setup:
37
-
38
-
```
39
-
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_CLIENT_SECRET
41
-
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
-
43
-
echo $TANGLED_OAUTH_CLIENT_KID
44
-
1761667908
45
-
46
-
# if not, you can set it up yourself:
47
-
goat key generate -t P-256
48
-
Key Type: P-256 / secp256r1 / ES256 private key
49
-
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
-
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
-
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
52
-
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
-
54
-
# the secret key from above
55
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
56
-
57
-
# run redis in at a new shell to store oauth sessions
58
-
redis-server
59
-
```
60
-
61
-
## running knots and spindles
62
-
63
-
An end-to-end knot setup requires setting up a machine with
64
-
`sshd`, `AuthorizedKeysCommand`, and git user, which is
65
-
quite cumbersome. So the nix flake provides a
66
-
`nixosConfiguration` to do so.
67
-
68
-
<details>
69
-
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
70
-
71
-
In order to build Tangled's dev VM on macOS, you will
72
-
first need to set up a Linux Nix builder. The recommended
73
-
way to do so is to run a [`darwin.linux-builder`
74
-
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
75
-
and to register it in `nix.conf` as a builder for Linux
76
-
with the same architecture as your Mac (`linux-aarch64` if
77
-
you are using Apple Silicon).
78
-
79
-
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
80
-
> the tangled repo so that it doesn't conflict with the other VM. For example,
81
-
> you can do
82
-
>
83
-
> ```shell
84
-
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
85
-
> ```
86
-
>
87
-
> to store the builder VM in a temporary dir.
88
-
>
89
-
> You should read and follow [all the other intructions][darwin builder vm] to
90
-
> avoid subtle problems.
91
-
92
-
Alternatively, you can use any other method to set up a
93
-
Linux machine with `nix` installed that you can `sudo ssh`
94
-
into (in other words, root user on your Mac has to be able
95
-
to ssh into the Linux machine without entering a password)
96
-
and that has the same architecture as your Mac. See
97
-
[remote builder
98
-
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
99
-
for how to register such a builder in `nix.conf`.
100
-
101
-
> WARNING: If you'd like to use
102
-
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
103
-
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
104
-
> ssh` works can be tricky. It seems to be [possible with
105
-
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
106
-
107
-
</details>
108
-
109
-
To begin, grab your DID from http://localhost:3000/settings.
110
-
Then, set `TANGLED_VM_KNOT_OWNER` and
111
-
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
112
-
lightweight NixOS VM like so:
113
-
114
-
```bash
115
-
nix run --impure .#vm
116
-
117
-
# type `poweroff` at the shell to exit the VM
118
-
```
119
-
120
-
This starts a knot on port 6444, a spindle on port 6555
121
-
with `ssh` exposed on port 2222.
122
-
123
-
Once the services are running, head to
124
-
http://localhost:3000/settings/knots and hit verify. It should
125
-
verify the ownership of the services instantly if everything
126
-
went smoothly.
127
-
128
-
You can push repositories to this VM with this ssh config
129
-
block on your main machine:
130
-
131
-
```bash
132
-
Host nixos-shell
133
-
Hostname localhost
134
-
Port 2222
135
-
User git
136
-
IdentityFile ~/.ssh/my_tangled_key
137
-
```
138
-
139
-
Set up a remote called `local-dev` on a git repo:
140
-
141
-
```bash
142
-
git remote add local-dev git@nixos-shell:user/repo
143
-
git push local-dev main
144
-
```
145
-
146
-
### running a spindle
147
-
148
-
The above VM should already be running a spindle on
149
-
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
150
-
hit verify. You can then configure each repository to use
151
-
this spindle and run CI jobs.
152
-
153
-
Of interest when debugging spindles:
154
-
155
-
```
156
-
# service logs from journald:
157
-
journalctl -xeu spindle
158
-
159
-
# CI job logs from disk:
160
-
ls /var/log/spindle
161
-
162
-
# debugging spindle db:
163
-
sqlite3 /var/lib/spindle/spindle.db
164
-
165
-
# litecli has a nicer REPL interface:
166
-
litecli /var/lib/spindle/spindle.db
167
-
```
168
-
169
-
If for any reason you wish to disable either one of the
170
-
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
-
`services.tangled.spindle.enable` (or
172
-
`services.tangled.knot.enable`) to `false`.
+93
docs/highlight.theme
+93
docs/highlight.theme
···
1
+
{
2
+
"text-color": null,
3
+
"background-color": null,
4
+
"line-number-color": null,
5
+
"line-number-background-color": null,
6
+
"text-styles": {
7
+
"Annotation": {
8
+
"text-color": null,
9
+
"background-color": null,
10
+
"bold": false,
11
+
"italic": true,
12
+
"underline": false
13
+
},
14
+
"ControlFlow": {
15
+
"text-color": null,
16
+
"background-color": null,
17
+
"bold": true,
18
+
"italic": false,
19
+
"underline": false
20
+
},
21
+
"Error": {
22
+
"text-color": null,
23
+
"background-color": null,
24
+
"bold": true,
25
+
"italic": false,
26
+
"underline": false
27
+
},
28
+
"Alert": {
29
+
"text-color": null,
30
+
"background-color": null,
31
+
"bold": true,
32
+
"italic": false,
33
+
"underline": false
34
+
},
35
+
"Preprocessor": {
36
+
"text-color": null,
37
+
"background-color": null,
38
+
"bold": true,
39
+
"italic": false,
40
+
"underline": false
41
+
},
42
+
"Information": {
43
+
"text-color": null,
44
+
"background-color": null,
45
+
"bold": false,
46
+
"italic": true,
47
+
"underline": false
48
+
},
49
+
"Warning": {
50
+
"text-color": null,
51
+
"background-color": null,
52
+
"bold": false,
53
+
"italic": true,
54
+
"underline": false
55
+
},
56
+
"Documentation": {
57
+
"text-color": null,
58
+
"background-color": null,
59
+
"bold": false,
60
+
"italic": true,
61
+
"underline": false
62
+
},
63
+
"DataType": {
64
+
"text-color": "#8f4e8b",
65
+
"background-color": null,
66
+
"bold": false,
67
+
"italic": false,
68
+
"underline": false
69
+
},
70
+
"Comment": {
71
+
"text-color": null,
72
+
"background-color": null,
73
+
"bold": false,
74
+
"italic": true,
75
+
"underline": false
76
+
},
77
+
"CommentVar": {
78
+
"text-color": null,
79
+
"background-color": null,
80
+
"bold": false,
81
+
"italic": true,
82
+
"underline": false
83
+
},
84
+
"Keyword": {
85
+
"text-color": null,
86
+
"background-color": null,
87
+
"bold": true,
88
+
"italic": false,
89
+
"underline": false
90
+
}
91
+
}
92
+
}
93
+
-214
docs/knot-hosting.md
-214
docs/knot-hosting.md
···
1
-
# knot self-hosting guide
2
-
3
-
So you want to run your own knot server? Great! Here are a few prerequisites:
4
-
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
-
2. A (sub)domain name. People generally use `knot.example.com`.
7
-
3. A valid SSL certificate for your domain.
8
-
9
-
There's a couple of ways to get started:
10
-
* NixOS: refer to
11
-
[flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
12
-
* Docker: Documented at
13
-
[@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker)
14
-
(community maintained: support is not guaranteed!)
15
-
* Manual: Documented below.
16
-
17
-
## manual setup
18
-
19
-
First, clone this repository:
20
-
21
-
```
22
-
git clone https://tangled.org/@tangled.org/core
23
-
```
24
-
25
-
Then, build the `knot` CLI. This is the knot administration and operation tool.
26
-
For the purpose of this guide, we're only concerned with these subcommands:
27
-
28
-
* `knot server`: the main knot server process, typically run as a
29
-
supervised service
30
-
* `knot guard`: handles role-based access control for git over SSH
31
-
(you'll never have to run this yourself)
32
-
* `knot keys`: fetches SSH keys associated with your knot; we'll use
33
-
this to generate the SSH `AuthorizedKeysCommand`
34
-
35
-
```
36
-
cd core
37
-
export CGO_ENABLED=1
38
-
go build -o knot ./cmd/knot
39
-
```
40
-
41
-
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
-
44
-
```
45
-
sudo mv knot /usr/local/bin/knot
46
-
sudo chown root:root /usr/local/bin/knot
47
-
```
48
-
49
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really
50
-
specific permissions](https://stackoverflow.com/a/27638306). The
51
-
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
52
-
retrieve a user's public SSH keys dynamically for authentication. Let's
53
-
set that up.
54
-
55
-
```
56
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
57
-
Match User git
58
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
59
-
AuthorizedKeysCommandUser nobody
60
-
EOF
61
-
```
62
-
63
-
Then, reload `sshd`:
64
-
65
-
```
66
-
sudo systemctl reload ssh
67
-
```
68
-
69
-
Next, create the `git` user. We'll use the `git` user's home directory
70
-
to store repositories:
71
-
72
-
```
73
-
sudo adduser git
74
-
```
75
-
76
-
Create `/home/git/.knot.env` with the following, updating the values as
77
-
necessary. The `KNOT_SERVER_OWNER` should be set to your
78
-
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
79
-
80
-
```
81
-
KNOT_REPO_SCAN_PATH=/home/git
82
-
KNOT_SERVER_HOSTNAME=knot.example.com
83
-
APPVIEW_ENDPOINT=https://tangled.sh
84
-
KNOT_SERVER_OWNER=did:plc:foobar
85
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
86
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
87
-
```
88
-
89
-
If you run a Linux distribution that uses systemd, you can use the provided
90
-
service file to run the server. Copy
91
-
[`knotserver.service`](/systemd/knotserver.service)
92
-
to `/etc/systemd/system/`. Then, run:
93
-
94
-
```
95
-
systemctl enable knotserver
96
-
systemctl start knotserver
97
-
```
98
-
99
-
The last step is to configure a reverse proxy like Nginx or Caddy to front your
100
-
knot. Here's an example configuration for Nginx:
101
-
102
-
```
103
-
server {
104
-
listen 80;
105
-
listen [::]:80;
106
-
server_name knot.example.com;
107
-
108
-
location / {
109
-
proxy_pass http://localhost:5555;
110
-
proxy_set_header Host $host;
111
-
proxy_set_header X-Real-IP $remote_addr;
112
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
113
-
proxy_set_header X-Forwarded-Proto $scheme;
114
-
}
115
-
116
-
# wss endpoint for git events
117
-
location /events {
118
-
proxy_set_header X-Forwarded-For $remote_addr;
119
-
proxy_set_header Host $http_host;
120
-
proxy_set_header Upgrade websocket;
121
-
proxy_set_header Connection Upgrade;
122
-
proxy_pass http://localhost:5555;
123
-
}
124
-
# additional config for SSL/TLS go here.
125
-
}
126
-
127
-
```
128
-
129
-
Remember to use Let's Encrypt or similar to procure a certificate for your
130
-
knot domain.
131
-
132
-
You should now have a running knot server! You can finalize
133
-
your registration by hitting the `verify` button on the
134
-
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
135
-
a record on your PDS to announce the existence of the knot.
136
-
137
-
### custom paths
138
-
139
-
(This section applies to manual setup only. Docker users should edit the mounts
140
-
in `docker-compose.yml` instead.)
141
-
142
-
Right now, the database and repositories of your knot lives in `/home/git`. You
143
-
can move these paths if you'd like to store them in another folder. Be careful
144
-
when adjusting these paths:
145
-
146
-
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
147
-
any possible side effects. Remember to restart it once you're done.
148
-
* Make backups before moving in case something goes wrong.
149
-
* Make sure the `git` user can read and write from the new paths.
150
-
151
-
#### database
152
-
153
-
As an example, let's say the current database is at `/home/git/knotserver.db`,
154
-
and we want to move it to `/home/git/database/knotserver.db`.
155
-
156
-
Copy the current database to the new location. Make sure to copy the `.db-shm`
157
-
and `.db-wal` files if they exist.
158
-
159
-
```
160
-
mkdir /home/git/database
161
-
cp /home/git/knotserver.db* /home/git/database
162
-
```
163
-
164
-
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
165
-
the new file path (_not_ the directory):
166
-
167
-
```
168
-
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
169
-
```
170
-
171
-
#### repositories
172
-
173
-
As an example, let's say the repositories are currently in `/home/git`, and we
174
-
want to move them into `/home/git/repositories`.
175
-
176
-
Create the new folder, then move the existing repositories (if there are any):
177
-
178
-
```
179
-
mkdir /home/git/repositories
180
-
# move all DIDs into the new folder; these will vary for you!
181
-
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
182
-
```
183
-
184
-
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
185
-
to the new directory:
186
-
187
-
```
188
-
KNOT_REPO_SCAN_PATH=/home/git/repositories
189
-
```
190
-
191
-
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
192
-
repository path:
193
-
194
-
```
195
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
196
-
Match User git
197
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
198
-
AuthorizedKeysCommandUser nobody
199
-
EOF
200
-
```
201
-
202
-
Make sure to restart your SSH server!
203
-
204
-
#### MOTD (message of the day)
205
-
206
-
To configure the MOTD used ("Welcome to this knot!" by default), edit the
207
-
`/home/git/motd` file:
208
-
209
-
```
210
-
printf "Hi from this knot!\n" > /home/git/motd
211
-
```
212
-
213
-
Note that you should add a newline at the end if setting a non-empty message
214
-
since the knot won't do this for you.
-59
docs/migrations.md
-59
docs/migrations.md
···
1
-
# Migrations
2
-
3
-
This document is laid out in reverse-chronological order.
4
-
Newer migration guides are listed first, and older guides
5
-
are further down the page.
6
-
7
-
## Upgrading from v1.8.x
8
-
9
-
After v1.8.2, the HTTP API for knot and spindles have been
10
-
deprecated and replaced with XRPC. Repositories on outdated
11
-
knots will not be viewable from the appview. Upgrading is
12
-
straightforward however.
13
-
14
-
For knots:
15
-
16
-
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
18
-
hit the "retry" button to verify your knot
19
-
20
-
For spindles:
21
-
22
-
- Upgrade to latest tag (v1.9.0 or above)
23
-
- Head to the [spindle
24
-
dashboard](https://tangled.org/settings/spindles) and hit the
25
-
"retry" button to verify your spindle
26
-
27
-
## Upgrading from v1.7.x
28
-
29
-
After v1.7.0, knot secrets have been deprecated. You no
30
-
longer need a secret from the appview to run a knot. All
31
-
authorized commands to knots are managed via [Inter-Service
32
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
-
Knots will be read-only until upgraded.
34
-
35
-
Upgrading is quite easy, in essence:
36
-
37
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
-
environment variable entirely
39
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
-
your DID. You can find your DID in the
41
-
[settings](https://tangled.org/settings) page.
42
-
- Restart your knot once you have replaced the environment
43
-
variable
44
-
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
45
-
hit the "retry" button to verify your knot. This simply
46
-
writes a `sh.tangled.knot` record to your PDS.
47
-
48
-
If you use the nix module, simply bump the flake to the
49
-
latest revision, and change your config block like so:
50
-
51
-
```diff
52
-
services.tangled.knot = {
53
-
enable = true;
54
-
server = {
55
-
- secretFile = /path/to/secret;
56
-
+ owner = "did:plc:foo";
57
-
};
58
-
};
59
-
```
+3
docs/mode.html
+3
docs/mode.html
+7
docs/search.html
+7
docs/search.html
···
1
+
<form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full">
2
+
<input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]">
3
+
<label>
4
+
<span style="display:none;">Search</span>
5
+
<input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal">
6
+
</label>
7
+
</form>
-25
docs/spindle/architecture.md
-25
docs/spindle/architecture.md
···
1
-
# spindle architecture
2
-
3
-
Spindle is a small CI runner service. Here's a high level overview of how it operates:
4
-
5
-
* listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
6
-
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
7
-
* when a new repo record comes through (typically when you add a spindle to a
8
-
repo from the settings), spindle then resolves the underlying knot and
9
-
subscribes to repo events (see:
10
-
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
11
-
* the spindle engine then handles execution of the pipeline, with results and
12
-
logs beamed on the spindle event stream over wss
13
-
14
-
### the engine
15
-
16
-
At present, the only supported backend is Docker (and Podman, if Docker
17
-
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18
-
executes each step in the pipeline in a fresh container, with state persisted
19
-
across steps within the `/tangled/workspace` directory.
20
-
21
-
The base image for the container is constructed on the fly using
22
-
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
23
-
used packages.
24
-
25
-
The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
-52
docs/spindle/hosting.md
···
1
-
# spindle self-hosting guide
2
-
3
-
## prerequisites
4
-
5
-
* Go
6
-
* Docker (the only supported backend currently)
7
-
8
-
## configuration
9
-
10
-
Spindle is configured using environment variables. The following environment variables are available:
11
-
12
-
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
13
-
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
14
-
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
15
-
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
16
-
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
17
-
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
18
-
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
19
-
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
20
-
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
21
-
22
-
## running spindle
23
-
24
-
1. **Set the environment variables.** For example:
25
-
26
-
```shell
27
-
export SPINDLE_SERVER_HOSTNAME="your-hostname"
28
-
export SPINDLE_SERVER_OWNER="your-did"
29
-
```
30
-
31
-
2. **Build the Spindle binary.**
32
-
33
-
```shell
34
-
cd core
35
-
go mod download
36
-
go build -o cmd/spindle/spindle cmd/spindle/main.go
37
-
```
38
-
39
-
3. **Create the log directory.**
40
-
41
-
```shell
42
-
sudo mkdir -p /var/log/spindle
43
-
sudo chown $USER:$USER -R /var/log/spindle
44
-
```
45
-
46
-
4. **Run the Spindle binary.**
47
-
48
-
```shell
49
-
./cmd/spindle/spindle
50
-
```
51
-
52
-
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
-285
docs/spindle/openbao.md
···
1
-
# spindle secrets with openbao
2
-
3
-
This document covers setting up Spindle to use OpenBao for secrets
4
-
management via OpenBao Proxy instead of the default SQLite backend.
5
-
6
-
## overview
7
-
8
-
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
-
authentication automatically using AppRole credentials, while Spindle
10
-
connects to the local proxy instead of directly to the OpenBao server.
11
-
12
-
This approach provides better security, automatic token renewal, and
13
-
simplified application code.
14
-
15
-
## installation
16
-
17
-
Install OpenBao from nixpkgs:
18
-
19
-
```bash
20
-
nix shell nixpkgs#openbao # for a local server
21
-
```
22
-
23
-
## setup
24
-
25
-
The setup process can is documented for both local development and production.
26
-
27
-
### local development
28
-
29
-
Start OpenBao in dev mode:
30
-
31
-
```bash
32
-
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
-
```
34
-
35
-
This starts OpenBao on `http://localhost:8201` with a root token.
36
-
37
-
Set up environment for bao CLI:
38
-
39
-
```bash
40
-
export BAO_ADDR=http://localhost:8200
41
-
export BAO_TOKEN=root
42
-
```
43
-
44
-
### production
45
-
46
-
You would typically use a systemd service with a configuration file. Refer to
47
-
[@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be
48
-
achieved using Nix.
49
-
50
-
Then, initialize the bao server:
51
-
```bash
52
-
bao operator init -key-shares=1 -key-threshold=1
53
-
```
54
-
55
-
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
-
```bash
57
-
bao operator unseal <unseal_key>
58
-
```
59
-
60
-
All steps below remain the same across both dev and production setups.
61
-
62
-
### configure openbao server
63
-
64
-
Create the spindle KV mount:
65
-
66
-
```bash
67
-
bao secrets enable -path=spindle -version=2 kv
68
-
```
69
-
70
-
Set up AppRole authentication and policy:
71
-
72
-
Create a policy file `spindle-policy.hcl`:
73
-
74
-
```hcl
75
-
# Full access to spindle KV v2 data
76
-
path "spindle/data/*" {
77
-
capabilities = ["create", "read", "update", "delete"]
78
-
}
79
-
80
-
# Access to metadata for listing and management
81
-
path "spindle/metadata/*" {
82
-
capabilities = ["list", "read", "delete", "update"]
83
-
}
84
-
85
-
# Allow listing at root level
86
-
path "spindle/" {
87
-
capabilities = ["list"]
88
-
}
89
-
90
-
# Required for connection testing and health checks
91
-
path "auth/token/lookup-self" {
92
-
capabilities = ["read"]
93
-
}
94
-
```
95
-
96
-
Apply the policy and create an AppRole:
97
-
98
-
```bash
99
-
bao policy write spindle-policy spindle-policy.hcl
100
-
bao auth enable approle
101
-
bao write auth/approle/role/spindle \
102
-
token_policies="spindle-policy" \
103
-
token_ttl=1h \
104
-
token_max_ttl=4h \
105
-
bind_secret_id=true \
106
-
secret_id_ttl=0 \
107
-
secret_id_num_uses=0
108
-
```
109
-
110
-
Get the credentials:
111
-
112
-
```bash
113
-
# Get role ID (static)
114
-
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
-
116
-
# Generate secret ID
117
-
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
-
119
-
echo "Role ID: $ROLE_ID"
120
-
echo "Secret ID: $SECRET_ID"
121
-
```
122
-
123
-
### create proxy configuration
124
-
125
-
Create the credential files:
126
-
127
-
```bash
128
-
# Create directory for OpenBao files
129
-
mkdir -p /tmp/openbao
130
-
131
-
# Save credentials
132
-
echo "$ROLE_ID" > /tmp/openbao/role-id
133
-
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
-
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
-
```
136
-
137
-
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
-
139
-
```hcl
140
-
# OpenBao server connection
141
-
vault {
142
-
address = "http://localhost:8200"
143
-
}
144
-
145
-
# Auto-Auth using AppRole
146
-
auto_auth {
147
-
method "approle" {
148
-
mount_path = "auth/approle"
149
-
config = {
150
-
role_id_file_path = "/tmp/openbao/role-id"
151
-
secret_id_file_path = "/tmp/openbao/secret-id"
152
-
}
153
-
}
154
-
155
-
# Optional: write token to file for debugging
156
-
sink "file" {
157
-
config = {
158
-
path = "/tmp/openbao/token"
159
-
mode = 0640
160
-
}
161
-
}
162
-
}
163
-
164
-
# Proxy listener for Spindle
165
-
listener "tcp" {
166
-
address = "127.0.0.1:8201"
167
-
tls_disable = true
168
-
}
169
-
170
-
# Enable API proxy with auto-auth token
171
-
api_proxy {
172
-
use_auto_auth_token = true
173
-
}
174
-
175
-
# Enable response caching
176
-
cache {
177
-
use_auto_auth_token = true
178
-
}
179
-
180
-
# Logging
181
-
log_level = "info"
182
-
```
183
-
184
-
### start the proxy
185
-
186
-
Start OpenBao Proxy:
187
-
188
-
```bash
189
-
bao proxy -config=/tmp/openbao/proxy.hcl
190
-
```
191
-
192
-
The proxy will authenticate with OpenBao and start listening on
193
-
`127.0.0.1:8201`.
194
-
195
-
### configure spindle
196
-
197
-
Set these environment variables for Spindle:
198
-
199
-
```bash
200
-
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
-
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
-
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
-
```
204
-
205
-
Start Spindle:
206
-
207
-
Spindle will now connect to the local proxy, which handles all
208
-
authentication automatically.
209
-
210
-
## production setup for proxy
211
-
212
-
For production, you'll want to run the proxy as a service:
213
-
214
-
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
-
proper TLS settings for the vault connection.
216
-
217
-
## verifying setup
218
-
219
-
Test the proxy directly:
220
-
221
-
```bash
222
-
# Check proxy health
223
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
-
225
-
# Test token lookup through proxy
226
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
-
```
228
-
229
-
Test OpenBao operations through the server:
230
-
231
-
```bash
232
-
# List all secrets
233
-
bao kv list spindle/
234
-
235
-
# Add a test secret via Spindle API, then check it exists
236
-
bao kv list spindle/repos/
237
-
238
-
# Get a specific secret
239
-
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
-
```
241
-
242
-
## how it works
243
-
244
-
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
-
- The proxy authenticates with OpenBao using AppRole credentials
246
-
- All Spindle requests go through the proxy, which injects authentication tokens
247
-
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
-
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
-
- The proxy handles all token renewal automatically
250
-
- Spindle no longer manages tokens or authentication directly
251
-
252
-
## troubleshooting
253
-
254
-
**Connection refused**: Check that the OpenBao Proxy is running and
255
-
listening on the configured address.
256
-
257
-
**403 errors**: Verify the AppRole credentials are correct and the policy
258
-
has the necessary permissions.
259
-
260
-
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
-
the mount creation step again.
262
-
263
-
**Proxy authentication failures**: Check the proxy logs and verify the
264
-
role-id and secret-id files are readable and contain valid credentials.
265
-
266
-
**Secret not found after writing**: This can indicate policy permission
267
-
issues. Verify the policy includes both `spindle/data/*` and
268
-
`spindle/metadata/*` paths with appropriate capabilities.
269
-
270
-
Check proxy logs:
271
-
272
-
```bash
273
-
# If running as systemd service
274
-
journalctl -u openbao-proxy -f
275
-
276
-
# If running directly, check the console output
277
-
```
278
-
279
-
Test AppRole authentication manually:
280
-
281
-
```bash
282
-
bao write auth/approle/login \
283
-
role_id="$(cat /tmp/openbao/role-id)" \
284
-
secret_id="$(cat /tmp/openbao/secret-id)"
285
-
```
-183
docs/spindle/pipeline.md
-183
docs/spindle/pipeline.md
···
1
-
# spindle pipelines
2
-
3
-
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
4
-
5
-
The fields are:
6
-
7
-
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
8
-
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
9
-
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
10
-
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
11
-
- [Environment](#environment): An **optional** field that allows you to define environment variables.
12
-
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
13
-
14
-
## Trigger
15
-
16
-
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
17
-
18
-
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
19
-
- `push`: The workflow should run every time a commit is pushed to the repository.
20
-
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
-
- `manual`: The workflow can be triggered manually.
22
-
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
-
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
24
-
25
-
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
26
-
27
-
```yaml
28
-
when:
29
-
- event: ["push", "manual"]
30
-
branch: ["main", "develop"]
31
-
- event: ["pull_request"]
32
-
branch: ["main"]
33
-
```
34
-
35
-
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
-
37
-
```yaml
38
-
when:
39
-
- event: ["push"]
40
-
tag: ["v*"]
41
-
```
42
-
43
-
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
-
45
-
```yaml
46
-
when:
47
-
- event: ["push"]
48
-
branch: ["main", "release-*"]
49
-
tag: ["v*", "stable"]
50
-
```
51
-
52
-
## Engine
53
-
54
-
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
55
-
56
-
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
57
-
58
-
Example:
59
-
60
-
```yaml
61
-
engine: "nixery"
62
-
```
63
-
64
-
## Clone options
65
-
66
-
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
67
-
68
-
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
69
-
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
70
-
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
71
-
72
-
The default settings are:
73
-
74
-
```yaml
75
-
clone:
76
-
skip: false
77
-
depth: 1
78
-
submodules: false
79
-
```
80
-
81
-
## Dependencies
82
-
83
-
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
84
-
85
-
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
86
-
87
-
```yaml
88
-
dependencies:
89
-
# nixpkgs
90
-
nixpkgs:
91
-
- nodejs
92
-
- go
93
-
# custom registry
94
-
git+https://tangled.org/@example.com/my_pkg:
95
-
- my_pkg
96
-
```
97
-
98
-
Now these dependencies are available to use in your workflow!
99
-
100
-
## Environment
101
-
102
-
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
103
-
104
-
Example:
105
-
106
-
```yaml
107
-
environment:
108
-
GOOS: "linux"
109
-
GOARCH: "arm64"
110
-
NODE_ENV: "production"
111
-
MY_ENV_VAR: "MY_ENV_VALUE"
112
-
```
113
-
114
-
## Steps
115
-
116
-
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
117
-
118
-
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
119
-
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
120
-
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
121
-
122
-
Example:
123
-
124
-
```yaml
125
-
steps:
126
-
- name: "Build backend"
127
-
command: "go build"
128
-
environment:
129
-
GOOS: "darwin"
130
-
GOARCH: "arm64"
131
-
- name: "Build frontend"
132
-
command: "npm run build"
133
-
environment:
134
-
NODE_ENV: "production"
135
-
```
136
-
137
-
## Complete workflow
138
-
139
-
```yaml
140
-
# .tangled/workflows/build.yml
141
-
142
-
when:
143
-
- event: ["push", "manual"]
144
-
branch: ["main", "develop"]
145
-
- event: ["pull_request"]
146
-
branch: ["main"]
147
-
148
-
engine: "nixery"
149
-
150
-
# using the default values
151
-
clone:
152
-
skip: false
153
-
depth: 1
154
-
submodules: false
155
-
156
-
dependencies:
157
-
# nixpkgs
158
-
nixpkgs:
159
-
- nodejs
160
-
- go
161
-
# custom registry
162
-
git+https://tangled.org/@example.com/my_pkg:
163
-
- my_pkg
164
-
165
-
environment:
166
-
GOOS: "linux"
167
-
GOARCH: "arm64"
168
-
NODE_ENV: "production"
169
-
MY_ENV_VAR: "MY_ENV_VALUE"
170
-
171
-
steps:
172
-
- name: "Build backend"
173
-
command: "go build"
174
-
environment:
175
-
GOOS: "darwin"
176
-
GOARCH: "arm64"
177
-
- name: "Build frontend"
178
-
command: "npm run build"
179
-
environment:
180
-
NODE_ENV: "production"
181
-
```
182
-
183
-
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
+101
docs/styles.css
···
1
+
svg {
2
+
width: 16px;
3
+
height: 16px;
4
+
}
5
+
6
+
:root {
7
+
--syntax-alert: #d20f39;
8
+
--syntax-annotation: #fe640b;
9
+
--syntax-attribute: #df8e1d;
10
+
--syntax-basen: #40a02b;
11
+
--syntax-builtin: #1e66f5;
12
+
--syntax-controlflow: #8839ef;
13
+
--syntax-char: #04a5e5;
14
+
--syntax-constant: #fe640b;
15
+
--syntax-comment: #9ca0b0;
16
+
--syntax-commentvar: #7c7f93;
17
+
--syntax-documentation: #9ca0b0;
18
+
--syntax-datatype: #df8e1d;
19
+
--syntax-decval: #40a02b;
20
+
--syntax-error: #d20f39;
21
+
--syntax-extension: #4c4f69;
22
+
--syntax-float: #40a02b;
23
+
--syntax-function: #1e66f5;
24
+
--syntax-import: #40a02b;
25
+
--syntax-information: #04a5e5;
26
+
--syntax-keyword: #8839ef;
27
+
--syntax-operator: #179299;
28
+
--syntax-other: #8839ef;
29
+
--syntax-preprocessor: #ea76cb;
30
+
--syntax-specialchar: #04a5e5;
31
+
--syntax-specialstring: #ea76cb;
32
+
--syntax-string: #40a02b;
33
+
--syntax-variable: #8839ef;
34
+
--syntax-verbatimstring: #40a02b;
35
+
--syntax-warning: #df8e1d;
36
+
}
37
+
38
+
@media (prefers-color-scheme: dark) {
39
+
:root {
40
+
--syntax-alert: #f38ba8;
41
+
--syntax-annotation: #fab387;
42
+
--syntax-attribute: #f9e2af;
43
+
--syntax-basen: #a6e3a1;
44
+
--syntax-builtin: #89b4fa;
45
+
--syntax-controlflow: #cba6f7;
46
+
--syntax-char: #89dceb;
47
+
--syntax-constant: #fab387;
48
+
--syntax-comment: #6c7086;
49
+
--syntax-commentvar: #585b70;
50
+
--syntax-documentation: #6c7086;
51
+
--syntax-datatype: #f9e2af;
52
+
--syntax-decval: #a6e3a1;
53
+
--syntax-error: #f38ba8;
54
+
--syntax-extension: #cdd6f4;
55
+
--syntax-float: #a6e3a1;
56
+
--syntax-function: #89b4fa;
57
+
--syntax-import: #a6e3a1;
58
+
--syntax-information: #89dceb;
59
+
--syntax-keyword: #cba6f7;
60
+
--syntax-operator: #94e2d5;
61
+
--syntax-other: #cba6f7;
62
+
--syntax-preprocessor: #f5c2e7;
63
+
--syntax-specialchar: #89dceb;
64
+
--syntax-specialstring: #f5c2e7;
65
+
--syntax-string: #a6e3a1;
66
+
--syntax-variable: #cba6f7;
67
+
--syntax-verbatimstring: #a6e3a1;
68
+
--syntax-warning: #f9e2af;
69
+
}
70
+
}
71
+
72
+
/* pandoc syntax highlighting classes */
73
+
code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */
74
+
code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */
75
+
code span.at { color: var(--syntax-attribute); } /* attribute */
76
+
code span.bn { color: var(--syntax-basen); } /* basen */
77
+
code span.bu { color: var(--syntax-builtin); } /* builtin */
78
+
code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */
79
+
code span.ch { color: var(--syntax-char); } /* char */
80
+
code span.cn { color: var(--syntax-constant); } /* constant */
81
+
code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */
82
+
code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */
83
+
code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */
84
+
code span.dt { color: var(--syntax-datatype); } /* datatype */
85
+
code span.dv { color: var(--syntax-decval); } /* decval */
86
+
code span.er { color: var(--syntax-error); font-weight: bold; } /* error */
87
+
code span.ex { color: var(--syntax-extension); } /* extension */
88
+
code span.fl { color: var(--syntax-float); } /* float */
89
+
code span.fu { color: var(--syntax-function); } /* function */
90
+
code span.im { color: var(--syntax-import); font-weight: bold; } /* import */
91
+
code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */
92
+
code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */
93
+
code span.op { color: var(--syntax-operator); } /* operator */
94
+
code span.ot { color: var(--syntax-other); } /* other */
95
+
code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */
96
+
code span.sc { color: var(--syntax-specialchar); } /* specialchar */
97
+
code span.ss { color: var(--syntax-specialstring); } /* specialstring */
98
+
code span.st { color: var(--syntax-string); } /* string */
99
+
code span.va { color: var(--syntax-variable); } /* variable */
100
+
code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */
101
+
code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+156
docs/template.html
+156
docs/template.html
···
1
+
<!DOCTYPE html>
2
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$>
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<meta name="generator" content="pandoc" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
7
+
$for(author-meta)$
8
+
<meta name="author" content="$author-meta$" />
9
+
$endfor$
10
+
11
+
$if(date-meta)$
12
+
<meta name="dcterms.date" content="$date-meta$" />
13
+
$endif$
14
+
15
+
$if(keywords)$
16
+
<meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" />
17
+
$endif$
18
+
19
+
$if(description-meta)$
20
+
<meta name="description" content="$description-meta$" />
21
+
$endif$
22
+
23
+
<title>$pagetitle$</title>
24
+
25
+
<style>
26
+
$styles.css()$
27
+
</style>
28
+
29
+
$for(css)$
30
+
<link rel="stylesheet" href="$css$" />
31
+
$endfor$
32
+
33
+
$for(header-includes)$
34
+
$header-includes$
35
+
$endfor$
36
+
37
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
+
39
+
</head>
40
+
<body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh">
41
+
$for(include-before)$
42
+
$include-before$
43
+
$endfor$
44
+
45
+
$if(toc)$
46
+
<!-- mobile TOC trigger -->
47
+
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
+
<button
49
+
type="button"
50
+
popovertarget="mobile-toc-popover"
51
+
popovertargetaction="toggle"
52
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
+
>
54
+
${ menu.svg() }
55
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
+
</button>
57
+
</div>
58
+
59
+
<div
60
+
id="mobile-toc-popover"
61
+
popover
62
+
class="mobile-toc-popover
63
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
64
+
h-full overflow-y-auto shadow-sm
65
+
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
66
+
>
67
+
<div class="flex flex-col min-h-full">
68
+
<div class="flex-1 space-y-4">
69
+
<button
70
+
type="button"
71
+
popovertarget="mobile-toc-popover"
72
+
popovertargetaction="toggle"
73
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
74
+
${ x.svg() }
75
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
+
</button>
77
+
${ search.html() }
78
+
${ table-of-contents:toc.html() }
79
+
</div>
80
+
${ single-page:mode.html() }
81
+
</div>
82
+
</div>
83
+
84
+
<!-- desktop sidebar toc -->
85
+
<nav
86
+
id="$idprefix$TOC"
87
+
role="doc-toc"
88
+
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
89
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
90
+
p-4 z-50 overflow-y-auto">
91
+
${ search.html() }
92
+
<div class="flex-1">
93
+
$if(toc-title)$
94
+
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
95
+
$endif$
96
+
${ table-of-contents:toc.html() }
97
+
</div>
98
+
${ single-page:mode.html() }
99
+
</nav>
100
+
$endif$
101
+
102
+
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
103
+
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
104
+
$if(top)$
105
+
$-- only print title block if this is NOT the top page
106
+
$else$
107
+
$if(title)$
108
+
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
109
+
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
110
+
$if(subtitle)$
111
+
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
112
+
$endif$
113
+
$for(author)$
114
+
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
115
+
$endfor$
116
+
$if(date)$
117
+
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
118
+
$endif$
119
+
$endif$
120
+
</header>
121
+
$endif$
122
+
123
+
$if(abstract)$
124
+
<article class="prose dark:prose-invert max-w-none">
125
+
$abstract$
126
+
</article>
127
+
$endif$
128
+
129
+
<article class="prose dark:prose-invert max-w-none">
130
+
$body$
131
+
</article>
132
+
</main>
133
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
134
+
<div class="max-w-4xl mx-auto px-8 py-4">
135
+
<div class="flex justify-between gap-4">
136
+
<span class="flex-1">
137
+
$if(previous.url)$
138
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span>
139
+
<a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a>
140
+
$endif$
141
+
</span>
142
+
<span class="flex-1 text-right">
143
+
$if(next.url)$
144
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span>
145
+
<a href="$next.url$" accesskey="n" rel="next">$next.title$</a>
146
+
$endif$
147
+
</span>
148
+
</div>
149
+
</div>
150
+
</nav>
151
+
</div>
152
+
$for(include-after)$
153
+
$include-after$
154
+
$endfor$
155
+
</body>
156
+
</html>
+4
docs/toc.html
+4
docs/toc.html
+3
-3
flake.lock
+3
-3
flake.lock
···
150
150
},
151
151
"nixpkgs": {
152
152
"locked": {
153
-
"lastModified": 1765186076,
154
-
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
153
+
"lastModified": 1766070988,
154
+
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
155
155
"owner": "nixos",
156
156
"repo": "nixpkgs",
157
-
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
157
+
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
158
158
"type": "github"
159
159
},
160
160
"original": {
+6
-3
flake.nix
+6
-3
flake.nix
···
76
76
};
77
77
buildGoApplication =
78
78
(self.callPackage "${gomod2nix}/builder" {
79
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
80
80
}).buildGoApplication;
81
81
modules = ./nix/gomod2nix.toml;
82
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
···
88
88
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
89
89
};
90
90
appview = self.callPackage ./nix/pkgs/appview.nix {};
91
+
docs = self.callPackage ./nix/pkgs/docs.nix {
92
+
inherit inter-fonts-src ibm-plex-mono-src lucide-src;
93
+
};
91
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
92
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
93
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
94
97
});
95
98
in {
96
99
overlays.default = final: prev: {
97
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
100
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs;
98
101
};
99
102
100
103
packages = forAllSystems (system: let
···
103
106
staticPackages = mkPackageSet pkgs.pkgsStatic;
104
107
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
105
108
in {
106
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
109
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs;
107
110
108
111
pkgsStatic-appview = staticPackages.appview;
109
112
pkgsStatic-knot = staticPackages.knot;
+2
-1
input.css
+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
+10
-2
lexicons/pulls/pull.json
···
12
12
"required": [
13
13
"target",
14
14
"title",
15
-
"patch",
15
+
"patchBlob",
16
16
"createdAt"
17
17
],
18
18
"properties": {
···
27
27
"type": "string"
28
28
},
29
29
"patch": {
30
-
"type": "string"
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": [
36
+
"text/x-patch"
37
+
],
38
+
"description": "patch content"
31
39
},
32
40
"source": {
33
41
"type": "ref",
+53
nix/pkgs/docs.nix
+53
nix/pkgs/docs.nix
···
1
+
{
2
+
pandoc,
3
+
tailwindcss,
4
+
runCommandLocal,
5
+
inter-fonts-src,
6
+
ibm-plex-mono-src,
7
+
lucide-src,
8
+
src,
9
+
}:
10
+
runCommandLocal "docs" {} ''
11
+
mkdir -p working
12
+
13
+
# copy templates, themes, styles, filters to working directory
14
+
cp ${src}/docs/*.html working/
15
+
cp ${src}/docs/*.theme working/
16
+
cp ${src}/docs/*.css working/
17
+
18
+
# icons
19
+
cp -rf ${lucide-src}/*.svg working/
20
+
21
+
# content - chunked
22
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
+
-o $out/ \
24
+
-t chunkedhtml \
25
+
--variable toc \
26
+
--variable-json single-page=false \
27
+
--toc-depth=2 \
28
+
--css=stylesheet.css \
29
+
--chunk-template="%i.html" \
30
+
--highlight-style=working/highlight.theme \
31
+
--template=working/template.html
32
+
33
+
# content - single page
34
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
35
+
-o $out/single-page.html \
36
+
--toc \
37
+
--variable toc \
38
+
--variable single-page \
39
+
--toc-depth=2 \
40
+
--css=stylesheet.css \
41
+
--highlight-style=working/highlight.theme \
42
+
--template=working/template.html
43
+
44
+
# fonts
45
+
mkdir -p $out/static/fonts
46
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/
47
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/
48
+
cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/
49
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/
50
+
51
+
# styles
52
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css
53
+
''
+1
-1
nix/vm.nix
+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
+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
+1
-1
spindle/motd
+31
-13
spindle/server.go
+31
-13
spindle/server.go
···
8
8
"log/slog"
9
9
"maps"
10
10
"net/http"
11
+
"sync"
11
12
12
13
"github.com/go-chi/chi/v5"
13
14
"tangled.org/core/api/tangled"
···
30
31
)
31
32
32
33
//go:embed motd
33
-
var motd []byte
34
+
var defaultMotd []byte
34
35
35
36
const (
36
37
rbacDomain = "thisserver"
37
38
)
38
39
39
40
type Spindle struct {
40
-
jc *jetstream.JetstreamClient
41
-
db *db.DB
42
-
e *rbac.Enforcer
43
-
l *slog.Logger
44
-
n *notifier.Notifier
45
-
engs map[string]models.Engine
46
-
jq *queue.Queue
47
-
cfg *config.Config
48
-
ks *eventconsumer.Consumer
49
-
res *idresolver.Resolver
50
-
vault secrets.Manager
41
+
jc *jetstream.JetstreamClient
42
+
db *db.DB
43
+
e *rbac.Enforcer
44
+
l *slog.Logger
45
+
n *notifier.Notifier
46
+
engs map[string]models.Engine
47
+
jq *queue.Queue
48
+
cfg *config.Config
49
+
ks *eventconsumer.Consumer
50
+
res *idresolver.Resolver
51
+
vault secrets.Manager
52
+
motd []byte
53
+
motdMu sync.RWMutex
51
54
}
52
55
53
56
// New creates a new Spindle server with the provided configuration and engines.
···
128
131
cfg: cfg,
129
132
res: resolver,
130
133
vault: vault,
134
+
motd: defaultMotd,
131
135
}
132
136
133
137
err = e.AddSpindle(rbacDomain)
···
201
205
return s.e
202
206
}
203
207
208
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
+
func (s *Spindle) SetMotdContent(content []byte) {
210
+
s.motdMu.Lock()
211
+
defer s.motdMu.Unlock()
212
+
s.motd = content
213
+
}
214
+
215
+
// GetMotdContent returns the current MOTD content.
216
+
func (s *Spindle) GetMotdContent() []byte {
217
+
s.motdMu.RLock()
218
+
defer s.motdMu.RUnlock()
219
+
return s.motd
220
+
}
221
+
204
222
// Start starts the Spindle server (blocking).
205
223
func (s *Spindle) Start(ctx context.Context) error {
206
224
// starts a job queue runner in the background
···
246
264
mux := chi.NewRouter()
247
265
248
266
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
249
-
w.Write(motd)
267
+
w.Write(s.GetMotdContent())
250
268
})
251
269
mux.HandleFunc("/events", s.Events)
252
270
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
+1
-1
tailwind.config.js
+1
-1
tailwind.config.js
···
2
2
const colors = require("tailwindcss/colors");
3
3
4
4
module.exports = {
5
-
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"],
5
+
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"],
6
6
darkMode: "media",
7
7
theme: {
8
8
container: {
+3
types/diff.go
+3
types/diff.go
+112
types/diff_test.go
+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
+
}