+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+4
-1
.gitignore
+4
-1
.gitignore
+31
api/tangled/actorprofile.go
+31
api/tangled/actorprofile.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.actor.profile
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
ActorProfileNSID = "sh.tangled.actor.profile"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.actor.profile", &ActorProfile{})
17
+
} //
18
+
// RECORDTYPE: ActorProfile
19
+
type ActorProfile struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"`
21
+
// bluesky: Include link to this account on Bluesky.
22
+
Bluesky bool `json:"bluesky" cborgen:"bluesky"`
23
+
// description: Free-form profile description text.
24
+
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
25
+
Links []string `json:"links,omitempty" cborgen:"links,omitempty"`
26
+
// location: Free-form location text.
27
+
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
28
+
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
29
+
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
30
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
31
+
}
+485
api/tangled/cbor_gen.go
+485
api/tangled/cbor_gen.go
···
3389
3389
3390
3390
return nil
3391
3391
}
3392
+
func (t *ActorProfile) MarshalCBOR(w io.Writer) error {
3393
+
if t == nil {
3394
+
_, err := w.Write(cbg.CborNull)
3395
+
return err
3396
+
}
3397
+
3398
+
cw := cbg.NewCborWriter(w)
3399
+
fieldCount := 7
3400
+
3401
+
if t.Description == nil {
3402
+
fieldCount--
3403
+
}
3404
+
3405
+
if t.Links == nil {
3406
+
fieldCount--
3407
+
}
3408
+
3409
+
if t.Location == nil {
3410
+
fieldCount--
3411
+
}
3412
+
3413
+
if t.PinnedRepositories == nil {
3414
+
fieldCount--
3415
+
}
3416
+
3417
+
if t.Stats == nil {
3418
+
fieldCount--
3419
+
}
3420
+
3421
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3422
+
return err
3423
+
}
3424
+
3425
+
// t.LexiconTypeID (string) (string)
3426
+
if len("$type") > 1000000 {
3427
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3428
+
}
3429
+
3430
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3431
+
return err
3432
+
}
3433
+
if _, err := cw.WriteString(string("$type")); err != nil {
3434
+
return err
3435
+
}
3436
+
3437
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.actor.profile"))); err != nil {
3438
+
return err
3439
+
}
3440
+
if _, err := cw.WriteString(string("sh.tangled.actor.profile")); err != nil {
3441
+
return err
3442
+
}
3443
+
3444
+
// t.Links ([]string) (slice)
3445
+
if t.Links != nil {
3446
+
3447
+
if len("links") > 1000000 {
3448
+
return xerrors.Errorf("Value in field \"links\" was too long")
3449
+
}
3450
+
3451
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil {
3452
+
return err
3453
+
}
3454
+
if _, err := cw.WriteString(string("links")); err != nil {
3455
+
return err
3456
+
}
3457
+
3458
+
if len(t.Links) > 8192 {
3459
+
return xerrors.Errorf("Slice value in field t.Links was too long")
3460
+
}
3461
+
3462
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil {
3463
+
return err
3464
+
}
3465
+
for _, v := range t.Links {
3466
+
if len(v) > 1000000 {
3467
+
return xerrors.Errorf("Value in field v was too long")
3468
+
}
3469
+
3470
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3471
+
return err
3472
+
}
3473
+
if _, err := cw.WriteString(string(v)); err != nil {
3474
+
return err
3475
+
}
3476
+
3477
+
}
3478
+
}
3479
+
3480
+
// t.Stats ([]string) (slice)
3481
+
if t.Stats != nil {
3482
+
3483
+
if len("stats") > 1000000 {
3484
+
return xerrors.Errorf("Value in field \"stats\" was too long")
3485
+
}
3486
+
3487
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil {
3488
+
return err
3489
+
}
3490
+
if _, err := cw.WriteString(string("stats")); err != nil {
3491
+
return err
3492
+
}
3493
+
3494
+
if len(t.Stats) > 8192 {
3495
+
return xerrors.Errorf("Slice value in field t.Stats was too long")
3496
+
}
3497
+
3498
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil {
3499
+
return err
3500
+
}
3501
+
for _, v := range t.Stats {
3502
+
if len(v) > 1000000 {
3503
+
return xerrors.Errorf("Value in field v was too long")
3504
+
}
3505
+
3506
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3507
+
return err
3508
+
}
3509
+
if _, err := cw.WriteString(string(v)); err != nil {
3510
+
return err
3511
+
}
3512
+
3513
+
}
3514
+
}
3515
+
3516
+
// t.Bluesky (bool) (bool)
3517
+
if len("bluesky") > 1000000 {
3518
+
return xerrors.Errorf("Value in field \"bluesky\" was too long")
3519
+
}
3520
+
3521
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil {
3522
+
return err
3523
+
}
3524
+
if _, err := cw.WriteString(string("bluesky")); err != nil {
3525
+
return err
3526
+
}
3527
+
3528
+
if err := cbg.WriteBool(w, t.Bluesky); err != nil {
3529
+
return err
3530
+
}
3531
+
3532
+
// t.Location (string) (string)
3533
+
if t.Location != nil {
3534
+
3535
+
if len("location") > 1000000 {
3536
+
return xerrors.Errorf("Value in field \"location\" was too long")
3537
+
}
3538
+
3539
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil {
3540
+
return err
3541
+
}
3542
+
if _, err := cw.WriteString(string("location")); err != nil {
3543
+
return err
3544
+
}
3545
+
3546
+
if t.Location == nil {
3547
+
if _, err := cw.Write(cbg.CborNull); err != nil {
3548
+
return err
3549
+
}
3550
+
} else {
3551
+
if len(*t.Location) > 1000000 {
3552
+
return xerrors.Errorf("Value in field t.Location was too long")
3553
+
}
3554
+
3555
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil {
3556
+
return err
3557
+
}
3558
+
if _, err := cw.WriteString(string(*t.Location)); err != nil {
3559
+
return err
3560
+
}
3561
+
}
3562
+
}
3563
+
3564
+
// t.Description (string) (string)
3565
+
if t.Description != nil {
3566
+
3567
+
if len("description") > 1000000 {
3568
+
return xerrors.Errorf("Value in field \"description\" was too long")
3569
+
}
3570
+
3571
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
3572
+
return err
3573
+
}
3574
+
if _, err := cw.WriteString(string("description")); err != nil {
3575
+
return err
3576
+
}
3577
+
3578
+
if t.Description == nil {
3579
+
if _, err := cw.Write(cbg.CborNull); err != nil {
3580
+
return err
3581
+
}
3582
+
} else {
3583
+
if len(*t.Description) > 1000000 {
3584
+
return xerrors.Errorf("Value in field t.Description was too long")
3585
+
}
3586
+
3587
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil {
3588
+
return err
3589
+
}
3590
+
if _, err := cw.WriteString(string(*t.Description)); err != nil {
3591
+
return err
3592
+
}
3593
+
}
3594
+
}
3595
+
3596
+
// t.PinnedRepositories ([]string) (slice)
3597
+
if t.PinnedRepositories != nil {
3598
+
3599
+
if len("pinnedRepositories") > 1000000 {
3600
+
return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long")
3601
+
}
3602
+
3603
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil {
3604
+
return err
3605
+
}
3606
+
if _, err := cw.WriteString(string("pinnedRepositories")); err != nil {
3607
+
return err
3608
+
}
3609
+
3610
+
if len(t.PinnedRepositories) > 8192 {
3611
+
return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long")
3612
+
}
3613
+
3614
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil {
3615
+
return err
3616
+
}
3617
+
for _, v := range t.PinnedRepositories {
3618
+
if len(v) > 1000000 {
3619
+
return xerrors.Errorf("Value in field v was too long")
3620
+
}
3621
+
3622
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3623
+
return err
3624
+
}
3625
+
if _, err := cw.WriteString(string(v)); err != nil {
3626
+
return err
3627
+
}
3628
+
3629
+
}
3630
+
}
3631
+
return nil
3632
+
}
3633
+
3634
+
func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) {
3635
+
*t = ActorProfile{}
3636
+
3637
+
cr := cbg.NewCborReader(r)
3638
+
3639
+
maj, extra, err := cr.ReadHeader()
3640
+
if err != nil {
3641
+
return err
3642
+
}
3643
+
defer func() {
3644
+
if err == io.EOF {
3645
+
err = io.ErrUnexpectedEOF
3646
+
}
3647
+
}()
3648
+
3649
+
if maj != cbg.MajMap {
3650
+
return fmt.Errorf("cbor input should be of type map")
3651
+
}
3652
+
3653
+
if extra > cbg.MaxLength {
3654
+
return fmt.Errorf("ActorProfile: map struct too large (%d)", extra)
3655
+
}
3656
+
3657
+
n := extra
3658
+
3659
+
nameBuf := make([]byte, 18)
3660
+
for i := uint64(0); i < n; i++ {
3661
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3662
+
if err != nil {
3663
+
return err
3664
+
}
3665
+
3666
+
if !ok {
3667
+
// Field doesn't exist on this type, so ignore it
3668
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3669
+
return err
3670
+
}
3671
+
continue
3672
+
}
3673
+
3674
+
switch string(nameBuf[:nameLen]) {
3675
+
// t.LexiconTypeID (string) (string)
3676
+
case "$type":
3677
+
3678
+
{
3679
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3680
+
if err != nil {
3681
+
return err
3682
+
}
3683
+
3684
+
t.LexiconTypeID = string(sval)
3685
+
}
3686
+
// t.Links ([]string) (slice)
3687
+
case "links":
3688
+
3689
+
maj, extra, err = cr.ReadHeader()
3690
+
if err != nil {
3691
+
return err
3692
+
}
3693
+
3694
+
if extra > 8192 {
3695
+
return fmt.Errorf("t.Links: array too large (%d)", extra)
3696
+
}
3697
+
3698
+
if maj != cbg.MajArray {
3699
+
return fmt.Errorf("expected cbor array")
3700
+
}
3701
+
3702
+
if extra > 0 {
3703
+
t.Links = make([]string, extra)
3704
+
}
3705
+
3706
+
for i := 0; i < int(extra); i++ {
3707
+
{
3708
+
var maj byte
3709
+
var extra uint64
3710
+
var err error
3711
+
_ = maj
3712
+
_ = extra
3713
+
_ = err
3714
+
3715
+
{
3716
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3717
+
if err != nil {
3718
+
return err
3719
+
}
3720
+
3721
+
t.Links[i] = string(sval)
3722
+
}
3723
+
3724
+
}
3725
+
}
3726
+
// t.Stats ([]string) (slice)
3727
+
case "stats":
3728
+
3729
+
maj, extra, err = cr.ReadHeader()
3730
+
if err != nil {
3731
+
return err
3732
+
}
3733
+
3734
+
if extra > 8192 {
3735
+
return fmt.Errorf("t.Stats: array too large (%d)", extra)
3736
+
}
3737
+
3738
+
if maj != cbg.MajArray {
3739
+
return fmt.Errorf("expected cbor array")
3740
+
}
3741
+
3742
+
if extra > 0 {
3743
+
t.Stats = make([]string, extra)
3744
+
}
3745
+
3746
+
for i := 0; i < int(extra); i++ {
3747
+
{
3748
+
var maj byte
3749
+
var extra uint64
3750
+
var err error
3751
+
_ = maj
3752
+
_ = extra
3753
+
_ = err
3754
+
3755
+
{
3756
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3757
+
if err != nil {
3758
+
return err
3759
+
}
3760
+
3761
+
t.Stats[i] = string(sval)
3762
+
}
3763
+
3764
+
}
3765
+
}
3766
+
// t.Bluesky (bool) (bool)
3767
+
case "bluesky":
3768
+
3769
+
maj, extra, err = cr.ReadHeader()
3770
+
if err != nil {
3771
+
return err
3772
+
}
3773
+
if maj != cbg.MajOther {
3774
+
return fmt.Errorf("booleans must be major type 7")
3775
+
}
3776
+
switch extra {
3777
+
case 20:
3778
+
t.Bluesky = false
3779
+
case 21:
3780
+
t.Bluesky = true
3781
+
default:
3782
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
3783
+
}
3784
+
// t.Location (string) (string)
3785
+
case "location":
3786
+
3787
+
{
3788
+
b, err := cr.ReadByte()
3789
+
if err != nil {
3790
+
return err
3791
+
}
3792
+
if b != cbg.CborNull[0] {
3793
+
if err := cr.UnreadByte(); err != nil {
3794
+
return err
3795
+
}
3796
+
3797
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3798
+
if err != nil {
3799
+
return err
3800
+
}
3801
+
3802
+
t.Location = (*string)(&sval)
3803
+
}
3804
+
}
3805
+
// t.Description (string) (string)
3806
+
case "description":
3807
+
3808
+
{
3809
+
b, err := cr.ReadByte()
3810
+
if err != nil {
3811
+
return err
3812
+
}
3813
+
if b != cbg.CborNull[0] {
3814
+
if err := cr.UnreadByte(); err != nil {
3815
+
return err
3816
+
}
3817
+
3818
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3819
+
if err != nil {
3820
+
return err
3821
+
}
3822
+
3823
+
t.Description = (*string)(&sval)
3824
+
}
3825
+
}
3826
+
// t.PinnedRepositories ([]string) (slice)
3827
+
case "pinnedRepositories":
3828
+
3829
+
maj, extra, err = cr.ReadHeader()
3830
+
if err != nil {
3831
+
return err
3832
+
}
3833
+
3834
+
if extra > 8192 {
3835
+
return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra)
3836
+
}
3837
+
3838
+
if maj != cbg.MajArray {
3839
+
return fmt.Errorf("expected cbor array")
3840
+
}
3841
+
3842
+
if extra > 0 {
3843
+
t.PinnedRepositories = make([]string, extra)
3844
+
}
3845
+
3846
+
for i := 0; i < int(extra); i++ {
3847
+
{
3848
+
var maj byte
3849
+
var extra uint64
3850
+
var err error
3851
+
_ = maj
3852
+
_ = extra
3853
+
_ = err
3854
+
3855
+
{
3856
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3857
+
if err != nil {
3858
+
return err
3859
+
}
3860
+
3861
+
t.PinnedRepositories[i] = string(sval)
3862
+
}
3863
+
3864
+
}
3865
+
}
3866
+
3867
+
default:
3868
+
// Field doesn't exist on this type, so ignore it
3869
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3870
+
return err
3871
+
}
3872
+
}
3873
+
}
3874
+
3875
+
return nil
3876
+
}
-217
appview/auth/auth.go
-217
appview/auth/auth.go
···
1
-
package auth
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"net/http"
7
-
"time"
8
-
9
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
10
-
"github.com/bluesky-social/indigo/atproto/identity"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
-
"github.com/gorilla/sessions"
13
-
"tangled.sh/tangled.sh/core/appview"
14
-
)
15
-
16
-
type Auth struct {
17
-
Store *sessions.CookieStore
18
-
}
19
-
20
-
type AtSessionCreate struct {
21
-
comatproto.ServerCreateSession_Output
22
-
PDSEndpoint string
23
-
}
24
-
25
-
type AtSessionRefresh struct {
26
-
comatproto.ServerRefreshSession_Output
27
-
PDSEndpoint string
28
-
}
29
-
30
-
func Make(secret string) (*Auth, error) {
31
-
store := sessions.NewCookieStore([]byte(secret))
32
-
return &Auth{store}, nil
33
-
}
34
-
35
-
func (a *Auth) CreateInitialSession(ctx context.Context, resolved *identity.Identity, appPassword string) (*comatproto.ServerCreateSession_Output, error) {
36
-
37
-
pdsUrl := resolved.PDSEndpoint()
38
-
client := xrpc.Client{
39
-
Host: pdsUrl,
40
-
}
41
-
42
-
atSession, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{
43
-
Identifier: resolved.DID.String(),
44
-
Password: appPassword,
45
-
})
46
-
if err != nil {
47
-
return nil, fmt.Errorf("invalid app password")
48
-
}
49
-
50
-
return atSession, nil
51
-
}
52
-
53
-
// Sessionish is an interface that provides access to the common fields of both types.
54
-
type Sessionish interface {
55
-
GetAccessJwt() string
56
-
GetActive() *bool
57
-
GetDid() string
58
-
GetDidDoc() *interface{}
59
-
GetHandle() string
60
-
GetRefreshJwt() string
61
-
GetStatus() *string
62
-
}
63
-
64
-
// Create a wrapper type for ServerRefreshSession_Output
65
-
type RefreshSessionWrapper struct {
66
-
*comatproto.ServerRefreshSession_Output
67
-
}
68
-
69
-
func (s *RefreshSessionWrapper) GetAccessJwt() string {
70
-
return s.AccessJwt
71
-
}
72
-
73
-
func (s *RefreshSessionWrapper) GetActive() *bool {
74
-
return s.Active
75
-
}
76
-
77
-
func (s *RefreshSessionWrapper) GetDid() string {
78
-
return s.Did
79
-
}
80
-
81
-
func (s *RefreshSessionWrapper) GetDidDoc() *interface{} {
82
-
return s.DidDoc
83
-
}
84
-
85
-
func (s *RefreshSessionWrapper) GetHandle() string {
86
-
return s.Handle
87
-
}
88
-
89
-
func (s *RefreshSessionWrapper) GetRefreshJwt() string {
90
-
return s.RefreshJwt
91
-
}
92
-
93
-
func (s *RefreshSessionWrapper) GetStatus() *string {
94
-
return s.Status
95
-
}
96
-
97
-
// Create a wrapper type for ServerRefreshSession_Output
98
-
type CreateSessionWrapper struct {
99
-
*comatproto.ServerCreateSession_Output
100
-
}
101
-
102
-
func (s *CreateSessionWrapper) GetAccessJwt() string {
103
-
return s.AccessJwt
104
-
}
105
-
106
-
func (s *CreateSessionWrapper) GetActive() *bool {
107
-
return s.Active
108
-
}
109
-
110
-
func (s *CreateSessionWrapper) GetDid() string {
111
-
return s.Did
112
-
}
113
-
114
-
func (s *CreateSessionWrapper) GetDidDoc() *interface{} {
115
-
return s.DidDoc
116
-
}
117
-
118
-
func (s *CreateSessionWrapper) GetHandle() string {
119
-
return s.Handle
120
-
}
121
-
122
-
func (s *CreateSessionWrapper) GetRefreshJwt() string {
123
-
return s.RefreshJwt
124
-
}
125
-
126
-
func (s *CreateSessionWrapper) GetStatus() *string {
127
-
return s.Status
128
-
}
129
-
130
-
func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error {
131
-
clientSession, err := a.Store.Get(r, appview.SessionName)
132
-
if err != nil {
133
-
return fmt.Errorf("invalid session", err)
134
-
}
135
-
if clientSession.IsNew {
136
-
return fmt.Errorf("invalid session")
137
-
}
138
-
clientSession.Options.MaxAge = -1
139
-
return clientSession.Save(r, w)
140
-
}
141
-
142
-
func (a *Auth) StoreSession(r *http.Request, w http.ResponseWriter, atSessionish Sessionish, pdsEndpoint string) error {
143
-
clientSession, _ := a.Store.Get(r, appview.SessionName)
144
-
clientSession.Values[appview.SessionHandle] = atSessionish.GetHandle()
145
-
clientSession.Values[appview.SessionDid] = atSessionish.GetDid()
146
-
clientSession.Values[appview.SessionPds] = pdsEndpoint
147
-
clientSession.Values[appview.SessionAccessJwt] = atSessionish.GetAccessJwt()
148
-
clientSession.Values[appview.SessionRefreshJwt] = atSessionish.GetRefreshJwt()
149
-
clientSession.Values[appview.SessionExpiry] = time.Now().Add(time.Minute * 15).Format(time.RFC3339)
150
-
clientSession.Values[appview.SessionAuthenticated] = true
151
-
return clientSession.Save(r, w)
152
-
}
153
-
154
-
func (a *Auth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
155
-
clientSession, err := a.Store.Get(r, "appview-session")
156
-
if err != nil || clientSession.IsNew {
157
-
return nil, err
158
-
}
159
-
160
-
did := clientSession.Values["did"].(string)
161
-
pdsUrl := clientSession.Values["pds"].(string)
162
-
accessJwt := clientSession.Values["accessJwt"].(string)
163
-
refreshJwt := clientSession.Values["refreshJwt"].(string)
164
-
165
-
client := &xrpc.Client{
166
-
Host: pdsUrl,
167
-
Auth: &xrpc.AuthInfo{
168
-
AccessJwt: accessJwt,
169
-
RefreshJwt: refreshJwt,
170
-
Did: did,
171
-
},
172
-
}
173
-
174
-
return client, nil
175
-
}
176
-
177
-
func (a *Auth) GetSession(r *http.Request) (*sessions.Session, error) {
178
-
return a.Store.Get(r, appview.SessionName)
179
-
}
180
-
181
-
func (a *Auth) GetDid(r *http.Request) string {
182
-
clientSession, err := a.Store.Get(r, appview.SessionName)
183
-
if err != nil || clientSession.IsNew {
184
-
return ""
185
-
}
186
-
187
-
return clientSession.Values[appview.SessionDid].(string)
188
-
}
189
-
190
-
func (a *Auth) GetHandle(r *http.Request) string {
191
-
clientSession, err := a.Store.Get(r, appview.SessionName)
192
-
if err != nil || clientSession.IsNew {
193
-
return ""
194
-
}
195
-
196
-
return clientSession.Values[appview.SessionHandle].(string)
197
-
}
198
-
199
-
type User struct {
200
-
Handle string
201
-
Did string
202
-
Pds string
203
-
}
204
-
205
-
func (a *Auth) GetUser(r *http.Request) *User {
206
-
clientSession, err := a.Store.Get(r, appview.SessionName)
207
-
208
-
if err != nil || clientSession.IsNew {
209
-
return nil
210
-
}
211
-
212
-
return &User{
213
-
Handle: clientSession.Values[appview.SessionHandle].(string),
214
-
Did: clientSession.Values[appview.SessionDid].(string),
215
-
Pds: clientSession.Values[appview.SessionPds].(string),
216
-
}
217
-
}
+42
-10
appview/config.go
+42
-10
appview/config.go
···
6
6
"github.com/sethvargo/go-envconfig"
7
7
)
8
8
9
+
type CoreConfig struct {
10
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
11
+
DbPath string `env:"DB_PATH, default=appview.db"`
12
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
13
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
14
+
Dev bool `env:"DEV, default=false"`
15
+
}
16
+
17
+
type OAuthConfig struct {
18
+
Jwks string `env:"JWKS"`
19
+
}
20
+
21
+
type JetstreamConfig struct {
22
+
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
23
+
}
24
+
25
+
type ResendConfig struct {
26
+
ApiKey string `env:"API_KEY"`
27
+
}
28
+
29
+
type CamoConfig struct {
30
+
Host string `env:"HOST, default=https://camo.tangled.sh"`
31
+
SharedSecret string `env:"SHARED_SECRET"`
32
+
}
33
+
34
+
type AvatarConfig struct {
35
+
Host string `env:"HOST, default=https://avatar.tangled.sh"`
36
+
SharedSecret string `env:"SHARED_SECRET"`
37
+
}
38
+
39
+
type PosthogConfig struct {
40
+
ApiKey string `env:"API_KEY"`
41
+
Endpoint string `env:"ENDPOINT, default=https://eu.i.posthog.com"`
42
+
}
43
+
9
44
type Config struct {
10
-
CookieSecret string `env:"TANGLED_COOKIE_SECRET, default=00000000000000000000000000000000"`
11
-
DbPath string `env:"TANGLED_DB_PATH, default=appview.db"`
12
-
ListenAddr string `env:"TANGLED_LISTEN_ADDR, default=0.0.0.0:3000"`
13
-
Dev bool `env:"TANGLED_DEV, default=false"`
14
-
JetstreamEndpoint string `env:"TANGLED_JETSTREAM_ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
15
-
ResendApiKey string `env:"TANGLED_RESEND_API_KEY"`
16
-
CamoHost string `env:"TANGLED_CAMO_HOST, default=https://camo.tangled.sh"`
17
-
CamoSharedSecret string `env:"TANGLED_CAMO_SHARED_SECRET"`
18
-
AvatarSharedSecret string `env:"TANGLED_AVATAR_SHARED_SECRET"`
19
-
AvatarHost string `env:"TANGLED_AVATAR_HOST, default=https://avatar.tangled.sh"`
45
+
Core CoreConfig `env:",prefix=TANGLED_"`
46
+
Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"`
47
+
Resend ResendConfig `env:",prefix=TANGLED_RESEND_"`
48
+
Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"`
49
+
Camo CamoConfig `env:",prefix=TANGLED_CAMO_"`
50
+
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
51
+
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
20
52
}
21
53
22
54
func LoadConfig(ctx context.Context) (*Config, error) {
+3
appview/consts.go
+3
appview/consts.go
-16
appview/db/artifact.go
-16
appview/db/artifact.go
···
57
57
return err
58
58
}
59
59
60
-
type filter struct {
61
-
key string
62
-
arg any
63
-
}
64
-
65
-
func Filter(key string, arg any) filter {
66
-
return filter{
67
-
key: key,
68
-
arg: arg,
69
-
}
70
-
}
71
-
72
-
func (f filter) Condition() string {
73
-
return fmt.Sprintf("%s = ?", f.key)
74
-
}
75
-
76
60
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
77
61
var artifacts []Artifact
78
62
+171
appview/db/db.go
+171
appview/db/db.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
+
"fmt"
6
7
"log"
7
8
8
9
_ "github.com/mattn/go-sqlite3"
···
231
232
foreign key (repo_at) references repos(at_uri) on delete cascade
232
233
);
233
234
235
+
create table if not exists profile (
236
+
-- id
237
+
id integer primary key autoincrement,
238
+
did text not null,
239
+
240
+
-- data
241
+
description text not null,
242
+
include_bluesky integer not null default 0,
243
+
location text,
244
+
245
+
-- constraints
246
+
unique(did)
247
+
);
248
+
create table if not exists profile_links (
249
+
-- id
250
+
id integer primary key autoincrement,
251
+
did text not null,
252
+
253
+
-- data
254
+
link text not null,
255
+
256
+
-- constraints
257
+
foreign key (did) references profile(did) on delete cascade
258
+
);
259
+
create table if not exists profile_stats (
260
+
-- id
261
+
id integer primary key autoincrement,
262
+
did text not null,
263
+
264
+
-- data
265
+
kind text not null check (kind in (
266
+
"merged-pull-request-count",
267
+
"closed-pull-request-count",
268
+
"open-pull-request-count",
269
+
"open-issue-count",
270
+
"closed-issue-count",
271
+
"repository-count"
272
+
)),
273
+
274
+
-- constraints
275
+
foreign key (did) references profile(did) on delete cascade
276
+
);
277
+
create table if not exists profile_pinned_repositories (
278
+
-- id
279
+
id integer primary key autoincrement,
280
+
did text not null,
281
+
282
+
-- data
283
+
at_uri text not null,
284
+
285
+
-- constraints
286
+
unique(did, at_uri),
287
+
foreign key (did) references profile(did) on delete cascade,
288
+
foreign key (at_uri) references repos(at_uri) on delete cascade
289
+
);
290
+
291
+
create table if not exists oauth_requests (
292
+
id integer primary key autoincrement,
293
+
auth_server_iss text not null,
294
+
state text not null,
295
+
did text not null,
296
+
handle text not null,
297
+
pds_url text not null,
298
+
pkce_verifier text not null,
299
+
dpop_auth_server_nonce text not null,
300
+
dpop_private_jwk text not null
301
+
);
302
+
303
+
create table if not exists oauth_sessions (
304
+
id integer primary key autoincrement,
305
+
did text not null,
306
+
handle text not null,
307
+
pds_url text not null,
308
+
auth_server_iss text not null,
309
+
access_jwt text not null,
310
+
refresh_jwt text not null,
311
+
dpop_pds_nonce text,
312
+
dpop_auth_server_nonce text not null,
313
+
dpop_private_jwk text not null,
314
+
expiry text not null
315
+
);
316
+
234
317
create table if not exists migrations (
235
318
id integer primary key autoincrement,
236
319
name text unique
···
303
386
return err
304
387
})
305
388
389
+
// disable foreign-keys for the next migration
390
+
// NOTE: this cannot be done in a transaction, so it is run outside [0]
391
+
//
392
+
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
393
+
db.Exec("pragma foreign_keys = off;")
394
+
runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
395
+
_, err := tx.Exec(`
396
+
create table pulls_new (
397
+
-- identifiers
398
+
id integer primary key autoincrement,
399
+
pull_id integer not null,
400
+
401
+
-- at identifiers
402
+
repo_at text not null,
403
+
owner_did text not null,
404
+
rkey text not null,
405
+
406
+
-- content
407
+
title text not null,
408
+
body text not null,
409
+
target_branch text not null,
410
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
411
+
412
+
-- source info
413
+
source_branch text,
414
+
source_repo_at text,
415
+
416
+
-- stacking
417
+
stack_id text,
418
+
change_id text,
419
+
parent_change_id text,
420
+
421
+
-- meta
422
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
423
+
424
+
-- constraints
425
+
unique(repo_at, pull_id),
426
+
foreign key (repo_at) references repos(at_uri) on delete cascade
427
+
);
428
+
429
+
insert into pulls_new (
430
+
id, pull_id,
431
+
repo_at, owner_did, rkey,
432
+
title, body, target_branch, state,
433
+
source_branch, source_repo_at,
434
+
created
435
+
)
436
+
select
437
+
id, pull_id,
438
+
repo_at, owner_did, rkey,
439
+
title, body, target_branch, state,
440
+
source_branch, source_repo_at,
441
+
created
442
+
FROM pulls;
443
+
444
+
drop table pulls;
445
+
alter table pulls_new rename to pulls;
446
+
`)
447
+
return err
448
+
})
449
+
db.Exec("pragma foreign_keys = on;")
450
+
306
451
return &DB{db}, nil
307
452
}
308
453
···
348
493
349
494
return nil
350
495
}
496
+
497
+
type filter struct {
498
+
key string
499
+
arg any
500
+
cmp string
501
+
}
502
+
503
+
func FilterEq(key string, arg any) filter {
504
+
return filter{
505
+
key: key,
506
+
arg: arg,
507
+
cmp: "=",
508
+
}
509
+
}
510
+
511
+
func FilterNotEq(key string, arg any) filter {
512
+
return filter{
513
+
key: key,
514
+
arg: arg,
515
+
cmp: "<>",
516
+
}
517
+
}
518
+
519
+
func (f filter) Condition() string {
520
+
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
521
+
}
+173
appview/db/oauth.go
+173
appview/db/oauth.go
···
1
+
package db
2
+
3
+
type OAuthRequest struct {
4
+
ID uint
5
+
AuthserverIss string
6
+
Handle string
7
+
State string
8
+
Did string
9
+
PdsUrl string
10
+
PkceVerifier string
11
+
DpopAuthserverNonce string
12
+
DpopPrivateJwk string
13
+
}
14
+
15
+
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
16
+
_, err := e.Exec(`
17
+
insert into oauth_requests (
18
+
auth_server_iss,
19
+
state,
20
+
handle,
21
+
did,
22
+
pds_url,
23
+
pkce_verifier,
24
+
dpop_auth_server_nonce,
25
+
dpop_private_jwk
26
+
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
27
+
oauthRequest.AuthserverIss,
28
+
oauthRequest.State,
29
+
oauthRequest.Handle,
30
+
oauthRequest.Did,
31
+
oauthRequest.PdsUrl,
32
+
oauthRequest.PkceVerifier,
33
+
oauthRequest.DpopAuthserverNonce,
34
+
oauthRequest.DpopPrivateJwk,
35
+
)
36
+
return err
37
+
}
38
+
39
+
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
40
+
var req OAuthRequest
41
+
err := e.QueryRow(`
42
+
select
43
+
id,
44
+
auth_server_iss,
45
+
handle,
46
+
state,
47
+
did,
48
+
pds_url,
49
+
pkce_verifier,
50
+
dpop_auth_server_nonce,
51
+
dpop_private_jwk
52
+
from oauth_requests
53
+
where state = ?`, state).Scan(
54
+
&req.ID,
55
+
&req.AuthserverIss,
56
+
&req.Handle,
57
+
&req.State,
58
+
&req.Did,
59
+
&req.PdsUrl,
60
+
&req.PkceVerifier,
61
+
&req.DpopAuthserverNonce,
62
+
&req.DpopPrivateJwk,
63
+
)
64
+
return req, err
65
+
}
66
+
67
+
func DeleteOAuthRequestByState(e Execer, state string) error {
68
+
_, err := e.Exec(`
69
+
delete from oauth_requests
70
+
where state = ?`, state)
71
+
return err
72
+
}
73
+
74
+
type OAuthSession struct {
75
+
ID uint
76
+
Handle string
77
+
Did string
78
+
PdsUrl string
79
+
AccessJwt string
80
+
RefreshJwt string
81
+
AuthServerIss string
82
+
DpopPdsNonce string
83
+
DpopAuthserverNonce string
84
+
DpopPrivateJwk string
85
+
Expiry string
86
+
}
87
+
88
+
func SaveOAuthSession(e Execer, session OAuthSession) error {
89
+
_, err := e.Exec(`
90
+
insert into oauth_sessions (
91
+
did,
92
+
handle,
93
+
pds_url,
94
+
access_jwt,
95
+
refresh_jwt,
96
+
auth_server_iss,
97
+
dpop_auth_server_nonce,
98
+
dpop_private_jwk,
99
+
expiry
100
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
101
+
session.Did,
102
+
session.Handle,
103
+
session.PdsUrl,
104
+
session.AccessJwt,
105
+
session.RefreshJwt,
106
+
session.AuthServerIss,
107
+
session.DpopAuthserverNonce,
108
+
session.DpopPrivateJwk,
109
+
session.Expiry,
110
+
)
111
+
return err
112
+
}
113
+
114
+
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
115
+
_, err := e.Exec(`
116
+
update oauth_sessions
117
+
set access_jwt = ?, refresh_jwt = ?, expiry = ?
118
+
where did = ?`,
119
+
accessJwt,
120
+
refreshJwt,
121
+
expiry,
122
+
did,
123
+
)
124
+
return err
125
+
}
126
+
127
+
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
128
+
var session OAuthSession
129
+
err := e.QueryRow(`
130
+
select
131
+
id,
132
+
did,
133
+
handle,
134
+
pds_url,
135
+
access_jwt,
136
+
refresh_jwt,
137
+
auth_server_iss,
138
+
dpop_auth_server_nonce,
139
+
dpop_private_jwk,
140
+
expiry
141
+
from oauth_sessions
142
+
where did = ?`, did).Scan(
143
+
&session.ID,
144
+
&session.Did,
145
+
&session.Handle,
146
+
&session.PdsUrl,
147
+
&session.AccessJwt,
148
+
&session.RefreshJwt,
149
+
&session.AuthServerIss,
150
+
&session.DpopAuthserverNonce,
151
+
&session.DpopPrivateJwk,
152
+
&session.Expiry,
153
+
)
154
+
return &session, err
155
+
}
156
+
157
+
func DeleteOAuthSessionByDid(e Execer, did string) error {
158
+
_, err := e.Exec(`
159
+
delete from oauth_sessions
160
+
where did = ?`, did)
161
+
return err
162
+
}
163
+
164
+
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
165
+
_, err := e.Exec(`
166
+
update oauth_sessions
167
+
set dpop_pds_nonce = ?
168
+
where did = ?`,
169
+
dpopPdsNonce,
170
+
did,
171
+
)
172
+
return err
173
+
}
+366
appview/db/profile.go
+366
appview/db/profile.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
4
5
"fmt"
6
+
"log"
7
+
"net/url"
8
+
"slices"
9
+
"strings"
5
10
"time"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
6
14
)
7
15
8
16
type RepoEvent struct {
···
162
170
163
171
return &timeline, nil
164
172
}
173
+
174
+
type Profile struct {
175
+
// ids
176
+
ID int
177
+
Did string
178
+
179
+
// data
180
+
Description string
181
+
IncludeBluesky bool
182
+
Location string
183
+
Links [5]string
184
+
Stats [2]VanityStat
185
+
PinnedRepos [6]syntax.ATURI
186
+
}
187
+
188
+
func (p Profile) IsLinksEmpty() bool {
189
+
for _, l := range p.Links {
190
+
if l != "" {
191
+
return false
192
+
}
193
+
}
194
+
return true
195
+
}
196
+
197
+
func (p Profile) IsStatsEmpty() bool {
198
+
for _, s := range p.Stats {
199
+
if s.Kind != "" {
200
+
return false
201
+
}
202
+
}
203
+
return true
204
+
}
205
+
206
+
func (p Profile) IsPinnedReposEmpty() bool {
207
+
for _, r := range p.PinnedRepos {
208
+
if r != "" {
209
+
return false
210
+
}
211
+
}
212
+
return true
213
+
}
214
+
215
+
type VanityStatKind string
216
+
217
+
const (
218
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
219
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
220
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
221
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
222
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
223
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
224
+
)
225
+
226
+
func (v VanityStatKind) String() string {
227
+
switch v {
228
+
case VanityStatMergedPRCount:
229
+
return "Merged PRs"
230
+
case VanityStatClosedPRCount:
231
+
return "Closed PRs"
232
+
case VanityStatOpenPRCount:
233
+
return "Open PRs"
234
+
case VanityStatOpenIssueCount:
235
+
return "Open Issues"
236
+
case VanityStatClosedIssueCount:
237
+
return "Closed Issues"
238
+
case VanityStatRepositoryCount:
239
+
return "Repositories"
240
+
}
241
+
return ""
242
+
}
243
+
244
+
type VanityStat struct {
245
+
Kind VanityStatKind
246
+
Value uint64
247
+
}
248
+
249
+
func (p *Profile) ProfileAt() syntax.ATURI {
250
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
251
+
}
252
+
253
+
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
254
+
defer tx.Rollback()
255
+
256
+
// update links
257
+
_, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did)
258
+
if err != nil {
259
+
return err
260
+
}
261
+
// update vanity stats
262
+
_, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did)
263
+
if err != nil {
264
+
return err
265
+
}
266
+
267
+
// update pinned repos
268
+
_, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did)
269
+
if err != nil {
270
+
return err
271
+
}
272
+
273
+
includeBskyValue := 0
274
+
if profile.IncludeBluesky {
275
+
includeBskyValue = 1
276
+
}
277
+
278
+
_, err = tx.Exec(
279
+
`insert or replace into profile (
280
+
did,
281
+
description,
282
+
include_bluesky,
283
+
location
284
+
)
285
+
values (?, ?, ?, ?)`,
286
+
profile.Did,
287
+
profile.Description,
288
+
includeBskyValue,
289
+
profile.Location,
290
+
)
291
+
292
+
if err != nil {
293
+
log.Println("profile", "err", err)
294
+
return err
295
+
}
296
+
297
+
for _, link := range profile.Links {
298
+
if link == "" {
299
+
continue
300
+
}
301
+
302
+
_, err := tx.Exec(
303
+
`insert into profile_links (did, link) values (?, ?)`,
304
+
profile.Did,
305
+
link,
306
+
)
307
+
308
+
if err != nil {
309
+
log.Println("profile_links", "err", err)
310
+
return err
311
+
}
312
+
}
313
+
314
+
for _, v := range profile.Stats {
315
+
if v.Kind == "" {
316
+
continue
317
+
}
318
+
319
+
_, err := tx.Exec(
320
+
`insert into profile_stats (did, kind) values (?, ?)`,
321
+
profile.Did,
322
+
v.Kind,
323
+
)
324
+
325
+
if err != nil {
326
+
log.Println("profile_stats", "err", err)
327
+
return err
328
+
}
329
+
}
330
+
331
+
for _, pin := range profile.PinnedRepos {
332
+
if pin == "" {
333
+
continue
334
+
}
335
+
336
+
_, err := tx.Exec(
337
+
`insert into profile_pinned_repositories (did, at_uri) values (?, ?)`,
338
+
profile.Did,
339
+
pin,
340
+
)
341
+
342
+
if err != nil {
343
+
log.Println("profile_pinned_repositories", "err", err)
344
+
return err
345
+
}
346
+
}
347
+
348
+
return tx.Commit()
349
+
}
350
+
351
+
func GetProfile(e Execer, did string) (*Profile, error) {
352
+
var profile Profile
353
+
profile.Did = did
354
+
355
+
includeBluesky := 0
356
+
err := e.QueryRow(
357
+
`select description, include_bluesky, location from profile where did = ?`,
358
+
did,
359
+
).Scan(&profile.Description, &includeBluesky, &profile.Location)
360
+
if err == sql.ErrNoRows {
361
+
profile := Profile{}
362
+
profile.Did = did
363
+
return &profile, nil
364
+
}
365
+
366
+
if err != nil {
367
+
return nil, err
368
+
}
369
+
370
+
if includeBluesky != 0 {
371
+
profile.IncludeBluesky = true
372
+
}
373
+
374
+
rows, err := e.Query(`select link from profile_links where did = ?`, did)
375
+
if err != nil {
376
+
return nil, err
377
+
}
378
+
defer rows.Close()
379
+
i := 0
380
+
for rows.Next() {
381
+
if err := rows.Scan(&profile.Links[i]); err != nil {
382
+
return nil, err
383
+
}
384
+
i++
385
+
}
386
+
387
+
rows, err = e.Query(`select kind from profile_stats where did = ?`, did)
388
+
if err != nil {
389
+
return nil, err
390
+
}
391
+
defer rows.Close()
392
+
i = 0
393
+
for rows.Next() {
394
+
if err := rows.Scan(&profile.Stats[i].Kind); err != nil {
395
+
return nil, err
396
+
}
397
+
value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
398
+
if err != nil {
399
+
return nil, err
400
+
}
401
+
profile.Stats[i].Value = value
402
+
i++
403
+
}
404
+
405
+
rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
406
+
if err != nil {
407
+
return nil, err
408
+
}
409
+
defer rows.Close()
410
+
i = 0
411
+
for rows.Next() {
412
+
if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
413
+
return nil, err
414
+
}
415
+
i++
416
+
}
417
+
418
+
return &profile, nil
419
+
}
420
+
421
+
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
422
+
query := ""
423
+
var args []any
424
+
switch stat {
425
+
case VanityStatMergedPRCount:
426
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
427
+
args = append(args, did, PullMerged)
428
+
case VanityStatClosedPRCount:
429
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
430
+
args = append(args, did, PullClosed)
431
+
case VanityStatOpenPRCount:
432
+
query = `select count(id) from pulls where owner_did = ? and state = ?`
433
+
args = append(args, did, PullOpen)
434
+
case VanityStatOpenIssueCount:
435
+
query = `select count(id) from issues where owner_did = ? and open = 1`
436
+
args = append(args, did)
437
+
case VanityStatClosedIssueCount:
438
+
query = `select count(id) from issues where owner_did = ? and open = 0`
439
+
args = append(args, did)
440
+
case VanityStatRepositoryCount:
441
+
query = `select count(id) from repos where did = ?`
442
+
args = append(args, did)
443
+
}
444
+
445
+
var result uint64
446
+
err := e.QueryRow(query, args...).Scan(&result)
447
+
if err != nil {
448
+
return 0, err
449
+
}
450
+
451
+
return result, nil
452
+
}
453
+
454
+
func ValidateProfile(e Execer, profile *Profile) error {
455
+
// ensure description is not too long
456
+
if len(profile.Description) > 256 {
457
+
return fmt.Errorf("Entered bio is too long.")
458
+
}
459
+
460
+
// ensure description is not too long
461
+
if len(profile.Location) > 40 {
462
+
return fmt.Errorf("Entered location is too long.")
463
+
}
464
+
465
+
// ensure links are in order
466
+
err := validateLinks(profile)
467
+
if err != nil {
468
+
return err
469
+
}
470
+
471
+
// ensure all pinned repos are either own repos or collaborating repos
472
+
repos, err := GetAllReposByDid(e, profile.Did)
473
+
if err != nil {
474
+
log.Printf("getting repos for %s: %s", profile.Did, err)
475
+
}
476
+
477
+
collaboratingRepos, err := CollaboratingIn(e, profile.Did)
478
+
if err != nil {
479
+
log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
480
+
}
481
+
482
+
var validRepos []syntax.ATURI
483
+
for _, r := range repos {
484
+
validRepos = append(validRepos, r.RepoAt())
485
+
}
486
+
for _, r := range collaboratingRepos {
487
+
validRepos = append(validRepos, r.RepoAt())
488
+
}
489
+
490
+
for _, pinned := range profile.PinnedRepos {
491
+
if pinned == "" {
492
+
continue
493
+
}
494
+
if !slices.Contains(validRepos, pinned) {
495
+
return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
496
+
}
497
+
}
498
+
499
+
return nil
500
+
}
501
+
502
+
func validateLinks(profile *Profile) error {
503
+
for i, link := range profile.Links {
504
+
if link == "" {
505
+
continue
506
+
}
507
+
508
+
parsedURL, err := url.Parse(link)
509
+
if err != nil {
510
+
return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
511
+
}
512
+
513
+
if parsedURL.Scheme == "" {
514
+
if strings.HasPrefix(link, "//") {
515
+
profile.Links[i] = "https:" + link
516
+
} else {
517
+
profile.Links[i] = "https://" + link
518
+
}
519
+
continue
520
+
} else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
521
+
return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
522
+
}
523
+
524
+
// catch relative paths
525
+
if parsedURL.Host == "" {
526
+
return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
527
+
}
528
+
}
529
+
return nil
530
+
}
+369
-40
appview/db/pulls.go
+369
-40
appview/db/pulls.go
···
4
4
"database/sql"
5
5
"fmt"
6
6
"log"
7
+
"slices"
7
8
"sort"
8
9
"strings"
9
10
"time"
···
21
22
PullClosed PullState = iota
22
23
PullOpen
23
24
PullMerged
25
+
PullDeleted
24
26
)
25
27
26
28
func (p PullState) String() string {
···
31
33
return "merged"
32
34
case PullClosed:
33
35
return "closed"
36
+
case PullDeleted:
37
+
return "deleted"
34
38
default:
35
39
return "closed"
36
40
}
···
44
48
}
45
49
func (p PullState) IsClosed() bool {
46
50
return p == PullClosed
51
+
}
52
+
func (p PullState) IsDeleted() bool {
53
+
return p == PullDeleted
47
54
}
48
55
49
56
type Pull struct {
···
63
70
State PullState
64
71
Submissions []*PullSubmission
65
72
73
+
// stacking
74
+
StackId string // nullable string
75
+
ChangeId string // nullable string
76
+
ParentChangeId string // nullable string
77
+
66
78
// meta
67
79
Created time.Time
68
80
PullSource *PullSource
···
71
83
Repo *Repo
72
84
}
73
85
86
+
func (p Pull) AsRecord() tangled.RepoPull {
87
+
var source *tangled.RepoPull_Source
88
+
if p.PullSource != nil {
89
+
s := p.PullSource.AsRecord()
90
+
source = &s
91
+
}
92
+
93
+
record := tangled.RepoPull{
94
+
Title: p.Title,
95
+
Body: &p.Body,
96
+
CreatedAt: p.Created.Format(time.RFC3339),
97
+
PullId: int64(p.PullId),
98
+
TargetRepo: p.RepoAt.String(),
99
+
TargetBranch: p.TargetBranch,
100
+
Patch: p.LatestPatch(),
101
+
Source: source,
102
+
}
103
+
return record
104
+
}
105
+
74
106
type PullSource struct {
75
107
Branch string
76
108
RepoAt *syntax.ATURI
···
79
111
Repo *Repo
80
112
}
81
113
114
+
func (p PullSource) AsRecord() tangled.RepoPull_Source {
115
+
var repoAt *string
116
+
if p.RepoAt != nil {
117
+
s := p.RepoAt.String()
118
+
repoAt = &s
119
+
}
120
+
record := tangled.RepoPull_Source{
121
+
Branch: p.Branch,
122
+
Repo: repoAt,
123
+
}
124
+
return record
125
+
}
126
+
82
127
type PullSubmission struct {
83
128
// ids
84
129
ID int
···
91
136
RoundNumber int
92
137
Patch string
93
138
Comments []PullComment
94
-
SourceRev string // include the rev that was used to create this submission: only for branch PRs
139
+
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
95
140
96
141
// meta
97
142
Created time.Time
···
152
197
}
153
198
}
154
199
return false
200
+
}
201
+
202
+
func (p *Pull) IsStacked() bool {
203
+
return p.StackId != ""
155
204
}
156
205
157
206
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
···
235
284
}
236
285
237
286
func NewPull(tx *sql.Tx, pull *Pull) error {
238
-
defer tx.Rollback()
239
-
240
287
_, err := tx.Exec(`
241
288
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
242
289
values (?, 1)
···
268
315
}
269
316
}
270
317
318
+
var stackId, changeId, parentChangeId *string
319
+
if pull.StackId != "" {
320
+
stackId = &pull.StackId
321
+
}
322
+
if pull.ChangeId != "" {
323
+
changeId = &pull.ChangeId
324
+
}
325
+
if pull.ParentChangeId != "" {
326
+
parentChangeId = &pull.ParentChangeId
327
+
}
328
+
271
329
_, err = tx.Exec(
272
330
`
273
-
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at)
274
-
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
331
+
insert into pulls (
332
+
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
333
+
)
334
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
275
335
pull.RepoAt,
276
336
pull.OwnerDid,
277
337
pull.PullId,
···
282
342
pull.State,
283
343
sourceBranch,
284
344
sourceRepoAt,
345
+
stackId,
346
+
changeId,
347
+
parentChangeId,
285
348
)
286
349
if err != nil {
287
350
return err
···
291
354
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
292
355
values (?, ?, ?, ?, ?)
293
356
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
294
-
if err != nil {
295
-
return err
296
-
}
297
-
298
-
if err := tx.Commit(); err != nil {
299
-
return err
300
-
}
301
-
302
-
return nil
357
+
return err
303
358
}
304
359
305
360
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
···
316
371
return pullId - 1, err
317
372
}
318
373
319
-
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) {
374
+
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
320
375
pulls := make(map[int]*Pull)
321
376
322
-
rows, err := e.Query(`
377
+
var conditions []string
378
+
var args []any
379
+
for _, filter := range filters {
380
+
conditions = append(conditions, filter.Condition())
381
+
args = append(args, filter.arg)
382
+
}
383
+
384
+
whereClause := ""
385
+
if conditions != nil {
386
+
whereClause = " where " + strings.Join(conditions, " and ")
387
+
}
388
+
389
+
query := fmt.Sprintf(`
323
390
select
324
391
owner_did,
392
+
repo_at,
325
393
pull_id,
326
394
created,
327
395
title,
···
330
398
body,
331
399
rkey,
332
400
source_branch,
333
-
source_repo_at
401
+
source_repo_at,
402
+
stack_id,
403
+
change_id,
404
+
parent_change_id
334
405
from
335
406
pulls
336
-
where
337
-
repo_at = ? and state = ?`, repoAt, state)
407
+
%s
408
+
`, whereClause)
409
+
410
+
rows, err := e.Query(query, args...)
338
411
if err != nil {
339
412
return nil, err
340
413
}
···
343
416
for rows.Next() {
344
417
var pull Pull
345
418
var createdAt string
346
-
var sourceBranch, sourceRepoAt sql.NullString
419
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
347
420
err := rows.Scan(
348
421
&pull.OwnerDid,
422
+
&pull.RepoAt,
349
423
&pull.PullId,
350
424
&createdAt,
351
425
&pull.Title,
···
355
429
&pull.Rkey,
356
430
&sourceBranch,
357
431
&sourceRepoAt,
432
+
&stackId,
433
+
&changeId,
434
+
&parentChangeId,
358
435
)
359
436
if err != nil {
360
437
return nil, err
···
379
456
}
380
457
}
381
458
459
+
if stackId.Valid {
460
+
pull.StackId = stackId.String
461
+
}
462
+
if changeId.Valid {
463
+
pull.ChangeId = changeId.String
464
+
}
465
+
if parentChangeId.Valid {
466
+
pull.ParentChangeId = parentChangeId.String
467
+
}
468
+
382
469
pulls[pull.PullId] = &pull
383
470
}
384
471
···
386
473
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
387
474
submissionsQuery := fmt.Sprintf(`
388
475
select
389
-
id, pull_id, round_number
476
+
id, pull_id, round_number, patch, source_rev
390
477
from
391
478
pull_submissions
392
479
where
393
-
repo_at = ? and pull_id in (%s)
394
-
`, inClause)
480
+
repo_at in (%s) and pull_id in (%s)
481
+
`, inClause, inClause)
395
482
396
-
args := make([]any, len(pulls)+1)
397
-
args[0] = repoAt.String()
398
-
idx := 1
483
+
args = make([]any, len(pulls)*2)
484
+
idx := 0
485
+
for _, p := range pulls {
486
+
args[idx] = p.RepoAt
487
+
idx += 1
488
+
}
399
489
for _, p := range pulls {
400
490
args[idx] = p.PullId
401
491
idx += 1
···
408
498
409
499
for submissionsRows.Next() {
410
500
var s PullSubmission
501
+
var sourceRev sql.NullString
411
502
err := submissionsRows.Scan(
412
503
&s.ID,
413
504
&s.PullId,
414
505
&s.RoundNumber,
506
+
&s.Patch,
507
+
&sourceRev,
415
508
)
416
509
if err != nil {
417
510
return nil, err
511
+
}
512
+
513
+
if sourceRev.Valid {
514
+
s.SourceRev = sourceRev.String
418
515
}
419
516
420
517
if p, ok := pulls[s.PullId]; ok {
···
466
563
return nil, err
467
564
}
468
565
469
-
orderedByDate := []*Pull{}
566
+
orderedByPullId := []*Pull{}
470
567
for _, p := range pulls {
471
-
orderedByDate = append(orderedByDate, p)
568
+
orderedByPullId = append(orderedByPullId, p)
472
569
}
473
-
sort.Slice(orderedByDate, func(i, j int) bool {
474
-
return orderedByDate[i].Created.After(orderedByDate[j].Created)
570
+
sort.Slice(orderedByPullId, func(i, j int) bool {
571
+
return orderedByPullId[i].PullId > orderedByPullId[j].PullId
475
572
})
476
573
477
-
return orderedByDate, nil
574
+
return orderedByPullId, nil
478
575
}
479
576
480
577
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
···
490
587
body,
491
588
rkey,
492
589
source_branch,
493
-
source_repo_at
590
+
source_repo_at,
591
+
stack_id,
592
+
change_id,
593
+
parent_change_id
494
594
from
495
595
pulls
496
596
where
···
500
600
501
601
var pull Pull
502
602
var createdAt string
503
-
var sourceBranch, sourceRepoAt sql.NullString
603
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
504
604
err := row.Scan(
505
605
&pull.OwnerDid,
506
606
&pull.PullId,
···
513
613
&pull.Rkey,
514
614
&sourceBranch,
515
615
&sourceRepoAt,
616
+
&stackId,
617
+
&changeId,
618
+
&parentChangeId,
516
619
)
517
620
if err != nil {
518
621
return nil, err
···
536
639
}
537
640
pull.PullSource.RepoAt = &sourceRepoAtParsed
538
641
}
642
+
}
643
+
644
+
if stackId.Valid {
645
+
pull.StackId = stackId.String
646
+
}
647
+
if changeId.Valid {
648
+
pull.ChangeId = changeId.String
649
+
}
650
+
if parentChangeId.Valid {
651
+
pull.ParentChangeId = parentChangeId.String
539
652
}
540
653
541
654
submissionsQuery := `
···
771
884
}
772
885
773
886
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
774
-
_, err := e.Exec(`update pulls set state = ? where repo_at = ? and pull_id = ?`, pullState, repoAt, pullId)
887
+
_, err := e.Exec(
888
+
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
889
+
pullState,
890
+
repoAt,
891
+
pullId,
892
+
PullDeleted, // only update state of non-deleted pulls
893
+
PullMerged, // only update state of non-merged pulls
894
+
)
775
895
return err
776
896
}
777
897
···
790
910
return err
791
911
}
792
912
913
+
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
914
+
err := SetPullState(e, repoAt, pullId, PullDeleted)
915
+
return err
916
+
}
917
+
793
918
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
794
919
newRoundNumber := len(pull.Submissions)
795
920
_, err := e.Exec(`
···
800
925
return err
801
926
}
802
927
928
+
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error {
929
+
var conditions []string
930
+
var args []any
931
+
932
+
args = append(args, parentChangeId)
933
+
934
+
for _, filter := range filters {
935
+
conditions = append(conditions, filter.Condition())
936
+
args = append(args, filter.arg)
937
+
}
938
+
939
+
whereClause := ""
940
+
if conditions != nil {
941
+
whereClause = " where " + strings.Join(conditions, " and ")
942
+
}
943
+
944
+
query := fmt.Sprintf("update pulls set parent_change_id = ? %s", whereClause)
945
+
_, err := e.Exec(query, args...)
946
+
947
+
return err
948
+
}
949
+
950
+
// Only used when stacking to update contents in the event of a rebase (the interdiff should be empty).
951
+
// otherwise submissions are immutable
952
+
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error {
953
+
var conditions []string
954
+
var args []any
955
+
956
+
args = append(args, sourceRev)
957
+
args = append(args, newPatch)
958
+
959
+
for _, filter := range filters {
960
+
conditions = append(conditions, filter.Condition())
961
+
args = append(args, filter.arg)
962
+
}
963
+
964
+
whereClause := ""
965
+
if conditions != nil {
966
+
whereClause = " where " + strings.Join(conditions, " and ")
967
+
}
968
+
969
+
query := fmt.Sprintf("update pull_submissions set source_rev = ?, patch = ? %s", whereClause)
970
+
_, err := e.Exec(query, args...)
971
+
972
+
return err
973
+
}
974
+
803
975
type PullCount struct {
804
-
Open int
805
-
Merged int
806
-
Closed int
976
+
Open int
977
+
Merged int
978
+
Closed int
979
+
Deleted int
807
980
}
808
981
809
982
func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) {
···
811
984
select
812
985
count(case when state = ? then 1 end) as open_count,
813
986
count(case when state = ? then 1 end) as merged_count,
814
-
count(case when state = ? then 1 end) as closed_count
987
+
count(case when state = ? then 1 end) as closed_count,
988
+
count(case when state = ? then 1 end) as deleted_count
815
989
from pulls
816
990
where repo_at = ?`,
817
991
PullOpen,
818
992
PullMerged,
819
993
PullClosed,
994
+
PullDeleted,
820
995
repoAt,
821
996
)
822
997
823
998
var count PullCount
824
-
if err := row.Scan(&count.Open, &count.Merged, &count.Closed); err != nil {
825
-
return PullCount{0, 0, 0}, err
999
+
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
1000
+
return PullCount{0, 0, 0, 0}, err
826
1001
}
827
1002
828
1003
return count, nil
829
1004
}
1005
+
1006
+
type Stack []*Pull
1007
+
1008
+
// change-id parent-change-id
1009
+
//
1010
+
// 4 w ,-------- z (TOP)
1011
+
// 3 z <----',------- y
1012
+
// 2 y <-----',------ x
1013
+
// 1 x <------' nil (BOT)
1014
+
//
1015
+
// `w` is parent of none, so it is the top of the stack
1016
+
func GetStack(e Execer, stackId string) (Stack, error) {
1017
+
unorderedPulls, err := GetPulls(
1018
+
e,
1019
+
FilterEq("stack_id", stackId),
1020
+
FilterNotEq("state", PullDeleted),
1021
+
)
1022
+
if err != nil {
1023
+
return nil, err
1024
+
}
1025
+
// map of parent-change-id to pull
1026
+
changeIdMap := make(map[string]*Pull, len(unorderedPulls))
1027
+
parentMap := make(map[string]*Pull, len(unorderedPulls))
1028
+
for _, p := range unorderedPulls {
1029
+
changeIdMap[p.ChangeId] = p
1030
+
if p.ParentChangeId != "" {
1031
+
parentMap[p.ParentChangeId] = p
1032
+
}
1033
+
}
1034
+
1035
+
// the top of the stack is the pull that is not a parent of any pull
1036
+
var topPull *Pull
1037
+
for _, maybeTop := range unorderedPulls {
1038
+
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
1039
+
topPull = maybeTop
1040
+
break
1041
+
}
1042
+
}
1043
+
1044
+
pulls := []*Pull{}
1045
+
for {
1046
+
pulls = append(pulls, topPull)
1047
+
if topPull.ParentChangeId != "" {
1048
+
if next, ok := changeIdMap[topPull.ParentChangeId]; ok {
1049
+
topPull = next
1050
+
} else {
1051
+
return nil, fmt.Errorf("failed to find parent pull request, stack is malformed")
1052
+
}
1053
+
} else {
1054
+
break
1055
+
}
1056
+
}
1057
+
1058
+
return pulls, nil
1059
+
}
1060
+
1061
+
func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) {
1062
+
pulls, err := GetPulls(
1063
+
e,
1064
+
FilterEq("stack_id", stackId),
1065
+
FilterEq("state", PullDeleted),
1066
+
)
1067
+
if err != nil {
1068
+
return nil, err
1069
+
}
1070
+
1071
+
return pulls, nil
1072
+
}
1073
+
1074
+
// position of this pull in the stack
1075
+
func (stack Stack) Position(pull *Pull) int {
1076
+
return slices.IndexFunc(stack, func(p *Pull) bool {
1077
+
return p.ChangeId == pull.ChangeId
1078
+
})
1079
+
}
1080
+
1081
+
// all pulls below this pull (including self) in this stack
1082
+
//
1083
+
// nil if this pull does not belong to this stack
1084
+
func (stack Stack) Below(pull *Pull) Stack {
1085
+
position := stack.Position(pull)
1086
+
1087
+
if position < 0 {
1088
+
return nil
1089
+
}
1090
+
1091
+
return stack[position:]
1092
+
}
1093
+
1094
+
// all pulls below this pull (excluding self) in this stack
1095
+
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
1096
+
below := stack.Below(pull)
1097
+
1098
+
if len(below) > 0 {
1099
+
return below[1:]
1100
+
}
1101
+
1102
+
return nil
1103
+
}
1104
+
1105
+
// all pulls above this pull (including self) in this stack
1106
+
func (stack Stack) Above(pull *Pull) Stack {
1107
+
position := stack.Position(pull)
1108
+
1109
+
if position < 0 {
1110
+
return nil
1111
+
}
1112
+
1113
+
return stack[:position+1]
1114
+
}
1115
+
1116
+
// all pulls below this pull (excluding self) in this stack
1117
+
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
1118
+
above := stack.Above(pull)
1119
+
1120
+
if len(above) > 0 {
1121
+
return above[:len(above)-1]
1122
+
}
1123
+
1124
+
return nil
1125
+
}
1126
+
1127
+
// the combined format-patches of all the newest submissions in this stack
1128
+
func (stack Stack) CombinedPatch() string {
1129
+
// go in reverse order because the bottom of the stack is the last element in the slice
1130
+
var combined strings.Builder
1131
+
for idx := range stack {
1132
+
pull := stack[len(stack)-1-idx]
1133
+
combined.WriteString(pull.LatestPatch())
1134
+
combined.WriteString("\n")
1135
+
}
1136
+
return combined.String()
1137
+
}
1138
+
1139
+
// filter out PRs that are "active"
1140
+
//
1141
+
// PRs that are still open are active
1142
+
func (stack Stack) Mergeable() Stack {
1143
+
var mergeable Stack
1144
+
1145
+
for _, p := range stack {
1146
+
// stop at the first merged PR
1147
+
if p.State == PullMerged || p.State == PullClosed {
1148
+
break
1149
+
}
1150
+
1151
+
// skip over deleted PRs
1152
+
if p.State != PullDeleted {
1153
+
mergeable = append(mergeable, p)
1154
+
}
1155
+
}
1156
+
1157
+
return mergeable
1158
+
}
+12
appview/db/repos.go
+12
appview/db/repos.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"fmt"
5
6
"time"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"tangled.sh/tangled.sh/core/api/tangled"
8
11
)
9
12
10
13
type Repo struct {
···
21
24
22
25
// optional
23
26
Source string
27
+
}
28
+
29
+
func (r Repo) RepoAt() syntax.ATURI {
30
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
31
+
}
32
+
33
+
func (r Repo) DidSlashRepo() string {
34
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
35
+
return p
24
36
}
25
37
26
38
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
+1
-1
appview/db/star.go
+1
-1
appview/db/star.go
···
71
71
72
72
// Remove a star
73
73
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
74
-
_, err := e.Exec(`delete or ignore from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
74
+
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
75
75
return err
76
76
}
77
77
+105
-6
appview/ingester.go
+105
-6
appview/ingester.go
···
13
13
"github.com/ipfs/go-cid"
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
15
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/rbac"
16
17
)
17
18
18
19
type Ingester func(ctx context.Context, e *models.Event) error
19
20
20
-
func Ingest(d db.DbWrapper) Ingester {
21
+
func Ingest(d db.DbWrapper, enforcer *rbac.Enforcer) Ingester {
21
22
return func(ctx context.Context, e *models.Event) error {
22
23
var err error
23
24
defer func() {
···
40
41
case tangled.PublicKeyNSID:
41
42
ingestPublicKey(&d, e)
42
43
case tangled.RepoArtifactNSID:
43
-
ingestArtifact(&d, e)
44
+
ingestArtifact(&d, e, enforcer)
45
+
case tangled.ActorProfileNSID:
46
+
ingestProfile(&d, e)
44
47
}
45
48
46
49
return err
···
137
140
return nil
138
141
}
139
142
140
-
func ingestArtifact(d *db.DbWrapper, e *models.Event) error {
143
+
func ingestArtifact(d *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error {
141
144
did := e.Did
142
145
var err error
143
146
144
147
switch e.Commit.Operation {
145
148
case models.CommitOperationCreate, models.CommitOperationUpdate:
146
-
log.Println("processing add of artifact")
147
149
raw := json.RawMessage(e.Commit.Record)
148
150
record := tangled.RepoArtifact{}
149
151
err = json.Unmarshal(raw, &record)
···
157
159
return err
158
160
}
159
161
162
+
repo, err := db.GetRepoByAtUri(d, repoAt.String())
163
+
if err != nil {
164
+
return err
165
+
}
166
+
167
+
ok, err := enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push")
168
+
if err != nil || !ok {
169
+
return err
170
+
}
171
+
160
172
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
161
173
if err != nil {
162
174
createdAt = time.Now()
···
176
188
177
189
err = db.AddArtifact(d, artifact)
178
190
case models.CommitOperationDelete:
179
-
log.Println("processing delete of artifact")
180
-
err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey))
191
+
err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
181
192
}
182
193
183
194
if err != nil {
···
186
197
187
198
return nil
188
199
}
200
+
201
+
func ingestProfile(d *db.DbWrapper, e *models.Event) error {
202
+
did := e.Did
203
+
var err error
204
+
205
+
if e.Commit.RKey != "self" {
206
+
return fmt.Errorf("ingestProfile only ingests `self` record")
207
+
}
208
+
209
+
switch e.Commit.Operation {
210
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
211
+
raw := json.RawMessage(e.Commit.Record)
212
+
record := tangled.ActorProfile{}
213
+
err = json.Unmarshal(raw, &record)
214
+
if err != nil {
215
+
log.Printf("invalid record: %s", err)
216
+
return err
217
+
}
218
+
219
+
description := ""
220
+
if record.Description != nil {
221
+
description = *record.Description
222
+
}
223
+
224
+
includeBluesky := record.Bluesky
225
+
226
+
location := ""
227
+
if record.Location != nil {
228
+
location = *record.Location
229
+
}
230
+
231
+
var links [5]string
232
+
for i, l := range record.Links {
233
+
if i < 5 {
234
+
links[i] = l
235
+
}
236
+
}
237
+
238
+
var stats [2]db.VanityStat
239
+
for i, s := range record.Stats {
240
+
if i < 2 {
241
+
stats[i].Kind = db.VanityStatKind(s)
242
+
}
243
+
}
244
+
245
+
var pinned [6]syntax.ATURI
246
+
for i, r := range record.PinnedRepositories {
247
+
if i < 6 {
248
+
pinned[i] = syntax.ATURI(r)
249
+
}
250
+
}
251
+
252
+
profile := db.Profile{
253
+
Did: did,
254
+
Description: description,
255
+
IncludeBluesky: includeBluesky,
256
+
Location: location,
257
+
Links: links,
258
+
Stats: stats,
259
+
PinnedRepos: pinned,
260
+
}
261
+
262
+
ddb, ok := d.Execer.(*db.DB)
263
+
if !ok {
264
+
return fmt.Errorf("failed to index profile record, invalid db cast")
265
+
}
266
+
267
+
tx, err := ddb.Begin()
268
+
if err != nil {
269
+
return fmt.Errorf("failed to start transaction")
270
+
}
271
+
272
+
err = db.ValidateProfile(tx, &profile)
273
+
if err != nil {
274
+
return fmt.Errorf("invalid profile record")
275
+
}
276
+
277
+
err = db.UpsertProfile(tx, &profile)
278
+
case models.CommitOperationDelete:
279
+
err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
280
+
}
281
+
282
+
if err != nil {
283
+
return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err)
284
+
}
285
+
286
+
return nil
287
+
}
+5
-58
appview/middleware/middleware.go
+5
-58
appview/middleware/middleware.go
···
5
5
"log"
6
6
"net/http"
7
7
"strconv"
8
-
"time"
9
8
10
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
-
"tangled.sh/tangled.sh/core/appview"
13
-
"tangled.sh/tangled.sh/core/appview/auth"
9
+
"tangled.sh/tangled.sh/core/appview/oauth"
14
10
"tangled.sh/tangled.sh/core/appview/pagination"
15
11
)
16
12
17
13
type Middleware func(http.Handler) http.Handler
18
14
19
-
func AuthMiddleware(a *auth.Auth) Middleware {
15
+
func AuthMiddleware(a *oauth.OAuth) Middleware {
20
16
return func(next http.Handler) http.Handler {
21
17
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
18
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
···
29
25
}
30
26
}
31
27
32
-
session, err := a.GetSession(r)
33
-
if session.IsNew || err != nil {
28
+
_, auth, err := a.GetSession(r)
29
+
if err != nil {
34
30
log.Printf("not logged in, redirecting")
35
31
redirectFunc(w, r)
36
32
return
37
33
}
38
34
39
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
40
-
if !ok || !authorized {
35
+
if !auth {
41
36
log.Printf("not logged in, redirecting")
42
37
redirectFunc(w, r)
43
38
return
44
-
}
45
-
46
-
// refresh if nearing expiry
47
-
// TODO: dedup with /login
48
-
expiryStr := session.Values[appview.SessionExpiry].(string)
49
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
50
-
if err != nil {
51
-
log.Println("invalid expiry time", err)
52
-
redirectFunc(w, r)
53
-
return
54
-
}
55
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
56
-
did, ok2 := session.Values[appview.SessionDid].(string)
57
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
58
-
59
-
if !ok1 || !ok2 || !ok3 {
60
-
log.Println("invalid expiry time", err)
61
-
redirectFunc(w, r)
62
-
return
63
-
}
64
-
65
-
if time.Now().After(expiry) {
66
-
log.Println("token expired, refreshing ...")
67
-
68
-
client := xrpc.Client{
69
-
Host: pdsUrl,
70
-
Auth: &xrpc.AuthInfo{
71
-
Did: did,
72
-
AccessJwt: refreshJwt,
73
-
RefreshJwt: refreshJwt,
74
-
},
75
-
}
76
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
77
-
if err != nil {
78
-
log.Println("failed to refresh session", err)
79
-
redirectFunc(w, r)
80
-
return
81
-
}
82
-
83
-
sessionish := auth.RefreshSessionWrapper{atSession}
84
-
85
-
err = a.StoreSession(r, w, &sessionish, pdsUrl)
86
-
if err != nil {
87
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
88
-
return
89
-
}
90
-
91
-
log.Println("successfully refreshed token")
92
39
}
93
40
94
41
next.ServeHTTP(w, r)
+24
appview/oauth/client/oauth_client.go
+24
appview/oauth/client/oauth_client.go
···
1
+
package client
2
+
3
+
import (
4
+
oauth "github.com/haileyok/atproto-oauth-golang"
5
+
"github.com/haileyok/atproto-oauth-golang/helpers"
6
+
)
7
+
8
+
type OAuthClient struct {
9
+
*oauth.Client
10
+
}
11
+
12
+
func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) {
13
+
k, err := helpers.ParseJWKFromBytes([]byte(clientJwk))
14
+
if err != nil {
15
+
return nil, err
16
+
}
17
+
18
+
cli, err := oauth.NewClient(oauth.ClientArgs{
19
+
ClientId: clientId,
20
+
ClientJwk: k,
21
+
RedirectUri: redirectUri,
22
+
})
23
+
return &OAuthClient{cli}, err
24
+
}
+321
appview/oauth/handler/handler.go
+321
appview/oauth/handler/handler.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"net/url"
9
+
"strings"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/gorilla/sessions"
13
+
"github.com/haileyok/atproto-oauth-golang/helpers"
14
+
"github.com/lestrrat-go/jwx/v2/jwk"
15
+
"github.com/posthog/posthog-go"
16
+
"tangled.sh/tangled.sh/core/appview"
17
+
"tangled.sh/tangled.sh/core/appview/db"
18
+
"tangled.sh/tangled.sh/core/appview/middleware"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
20
+
"tangled.sh/tangled.sh/core/appview/oauth/client"
21
+
"tangled.sh/tangled.sh/core/appview/pages"
22
+
"tangled.sh/tangled.sh/core/knotclient"
23
+
"tangled.sh/tangled.sh/core/rbac"
24
+
)
25
+
26
+
const (
27
+
oauthScope = "atproto transition:generic"
28
+
)
29
+
30
+
type OAuthHandler struct {
31
+
Config *appview.Config
32
+
Pages *pages.Pages
33
+
Resolver *appview.Resolver
34
+
Db *db.DB
35
+
Store *sessions.CookieStore
36
+
OAuth *oauth.OAuth
37
+
Enforcer *rbac.Enforcer
38
+
Posthog posthog.Client
39
+
}
40
+
41
+
func (o *OAuthHandler) Router() http.Handler {
42
+
r := chi.NewRouter()
43
+
44
+
r.Get("/login", o.login)
45
+
r.Post("/login", o.login)
46
+
47
+
r.With(middleware.AuthMiddleware(o.OAuth)).Post("/logout", o.logout)
48
+
49
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
50
+
r.Get("/oauth/jwks.json", o.jwks)
51
+
r.Get("/oauth/callback", o.callback)
52
+
return r
53
+
}
54
+
55
+
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
56
+
w.Header().Set("Content-Type", "application/json")
57
+
w.WriteHeader(http.StatusOK)
58
+
json.NewEncoder(w).Encode(o.OAuth.ClientMetadata())
59
+
}
60
+
61
+
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
62
+
jwks := o.Config.OAuth.Jwks
63
+
pubKey, err := pubKeyFromJwk(jwks)
64
+
if err != nil {
65
+
log.Printf("error parsing public key: %v", err)
66
+
http.Error(w, err.Error(), http.StatusInternalServerError)
67
+
return
68
+
}
69
+
70
+
response := helpers.CreateJwksResponseObject(pubKey)
71
+
72
+
w.Header().Set("Content-Type", "application/json")
73
+
w.WriteHeader(http.StatusOK)
74
+
json.NewEncoder(w).Encode(response)
75
+
}
76
+
77
+
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
78
+
switch r.Method {
79
+
case http.MethodGet:
80
+
o.Pages.Login(w, pages.LoginParams{})
81
+
case http.MethodPost:
82
+
handle := strings.TrimPrefix(r.FormValue("handle"), "@")
83
+
84
+
resolved, err := o.Resolver.ResolveIdent(r.Context(), handle)
85
+
if err != nil {
86
+
log.Println("failed to resolve handle:", err)
87
+
o.Pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
88
+
return
89
+
}
90
+
self := o.OAuth.ClientMetadata()
91
+
oauthClient, err := client.NewClient(
92
+
self.ClientID,
93
+
o.Config.OAuth.Jwks,
94
+
self.RedirectURIs[0],
95
+
)
96
+
97
+
if err != nil {
98
+
log.Println("failed to create oauth client:", err)
99
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
100
+
return
101
+
}
102
+
103
+
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
104
+
if err != nil {
105
+
log.Println("failed to resolve auth server:", err)
106
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
107
+
return
108
+
}
109
+
110
+
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
111
+
if err != nil {
112
+
log.Println("failed to fetch auth server metadata:", err)
113
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
114
+
return
115
+
}
116
+
117
+
dpopKey, err := helpers.GenerateKey(nil)
118
+
if err != nil {
119
+
log.Println("failed to generate dpop key:", err)
120
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
121
+
return
122
+
}
123
+
124
+
dpopKeyJson, err := json.Marshal(dpopKey)
125
+
if err != nil {
126
+
log.Println("failed to marshal dpop key:", err)
127
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
128
+
return
129
+
}
130
+
131
+
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
132
+
if err != nil {
133
+
log.Println("failed to send par auth request:", err)
134
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
135
+
return
136
+
}
137
+
138
+
err = db.SaveOAuthRequest(o.Db, db.OAuthRequest{
139
+
Did: resolved.DID.String(),
140
+
PdsUrl: resolved.PDSEndpoint(),
141
+
Handle: handle,
142
+
AuthserverIss: authMeta.Issuer,
143
+
PkceVerifier: parResp.PkceVerifier,
144
+
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
145
+
DpopPrivateJwk: string(dpopKeyJson),
146
+
State: parResp.State,
147
+
})
148
+
if err != nil {
149
+
log.Println("failed to save oauth request:", err)
150
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
151
+
return
152
+
}
153
+
154
+
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
155
+
query := url.Values{}
156
+
query.Add("client_id", self.ClientID)
157
+
query.Add("request_uri", parResp.RequestUri)
158
+
u.RawQuery = query.Encode()
159
+
o.Pages.HxRedirect(w, u.String())
160
+
}
161
+
}
162
+
163
+
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
164
+
state := r.FormValue("state")
165
+
166
+
oauthRequest, err := db.GetOAuthRequestByState(o.Db, state)
167
+
if err != nil {
168
+
log.Println("failed to get oauth request:", err)
169
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
170
+
return
171
+
}
172
+
173
+
defer func() {
174
+
err := db.DeleteOAuthRequestByState(o.Db, state)
175
+
if err != nil {
176
+
log.Println("failed to delete oauth request for state:", state, err)
177
+
}
178
+
}()
179
+
180
+
error := r.FormValue("error")
181
+
errorDescription := r.FormValue("error_description")
182
+
if error != "" || errorDescription != "" {
183
+
log.Printf("error: %s, %s", error, errorDescription)
184
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
185
+
return
186
+
}
187
+
188
+
code := r.FormValue("code")
189
+
if code == "" {
190
+
log.Println("missing code for state: ", state)
191
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
192
+
return
193
+
}
194
+
195
+
iss := r.FormValue("iss")
196
+
if iss == "" {
197
+
log.Println("missing iss for state: ", state)
198
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
199
+
return
200
+
}
201
+
202
+
self := o.OAuth.ClientMetadata()
203
+
204
+
oauthClient, err := client.NewClient(
205
+
self.ClientID,
206
+
o.Config.OAuth.Jwks,
207
+
self.RedirectURIs[0],
208
+
)
209
+
210
+
if err != nil {
211
+
log.Println("failed to create oauth client:", err)
212
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
213
+
return
214
+
}
215
+
216
+
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
217
+
if err != nil {
218
+
log.Println("failed to parse jwk:", err)
219
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
220
+
return
221
+
}
222
+
223
+
tokenResp, err := oauthClient.InitialTokenRequest(
224
+
r.Context(),
225
+
code,
226
+
oauthRequest.AuthserverIss,
227
+
oauthRequest.PkceVerifier,
228
+
oauthRequest.DpopAuthserverNonce,
229
+
jwk,
230
+
)
231
+
if err != nil {
232
+
log.Println("failed to get token:", err)
233
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
234
+
return
235
+
}
236
+
237
+
if tokenResp.Scope != oauthScope {
238
+
log.Println("scope doesn't match:", tokenResp.Scope)
239
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
240
+
return
241
+
}
242
+
243
+
err = o.OAuth.SaveSession(w, r, oauthRequest, tokenResp)
244
+
if err != nil {
245
+
log.Println("failed to save session:", err)
246
+
o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
247
+
return
248
+
}
249
+
250
+
log.Println("session saved successfully")
251
+
go o.addToDefaultKnot(oauthRequest.Did)
252
+
253
+
if !o.Config.Core.Dev {
254
+
err = o.Posthog.Enqueue(posthog.Capture{
255
+
DistinctId: oauthRequest.Did,
256
+
Event: "signin",
257
+
})
258
+
if err != nil {
259
+
log.Println("failed to enqueue posthog event:", err)
260
+
}
261
+
}
262
+
263
+
http.Redirect(w, r, "/", http.StatusFound)
264
+
}
265
+
266
+
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
267
+
err := o.OAuth.ClearSession(r, w)
268
+
if err != nil {
269
+
log.Println("failed to clear session:", err)
270
+
http.Redirect(w, r, "/", http.StatusFound)
271
+
return
272
+
}
273
+
274
+
log.Println("session cleared successfully")
275
+
http.Redirect(w, r, "/", http.StatusFound)
276
+
}
277
+
278
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
279
+
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
280
+
if err != nil {
281
+
return nil, err
282
+
}
283
+
pubKey, err := k.PublicKey()
284
+
if err != nil {
285
+
return nil, err
286
+
}
287
+
return pubKey, nil
288
+
}
289
+
290
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
291
+
defaultKnot := "knot1.tangled.sh"
292
+
293
+
log.Printf("adding %s to default knot", did)
294
+
err := o.Enforcer.AddMember(defaultKnot, did)
295
+
if err != nil {
296
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
297
+
return
298
+
}
299
+
err = o.Enforcer.E.SavePolicy()
300
+
if err != nil {
301
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
302
+
return
303
+
}
304
+
305
+
secret, err := db.GetRegistrationKey(o.Db, defaultKnot)
306
+
if err != nil {
307
+
log.Println("failed to get registration key for knot1.tangled.sh")
308
+
return
309
+
}
310
+
signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.Config.Core.Dev)
311
+
resp, err := signedClient.AddMember(did)
312
+
if err != nil {
313
+
log.Println("failed to add user to knot1.tangled.sh: ", err)
314
+
return
315
+
}
316
+
317
+
if resp.StatusCode != http.StatusNoContent {
318
+
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
319
+
return
320
+
}
321
+
}
+268
appview/oauth/oauth.go
+268
appview/oauth/oauth.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"net/url"
8
+
"time"
9
+
10
+
"github.com/gorilla/sessions"
11
+
oauth "github.com/haileyok/atproto-oauth-golang"
12
+
"github.com/haileyok/atproto-oauth-golang/helpers"
13
+
"tangled.sh/tangled.sh/core/appview"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/oauth/client"
16
+
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
17
+
)
18
+
19
+
type OAuthRequest struct {
20
+
ID uint
21
+
AuthserverIss string
22
+
State string
23
+
Did string
24
+
PdsUrl string
25
+
PkceVerifier string
26
+
DpopAuthserverNonce string
27
+
DpopPrivateJwk string
28
+
}
29
+
30
+
type OAuth struct {
31
+
Store *sessions.CookieStore
32
+
Db *db.DB
33
+
Config *appview.Config
34
+
}
35
+
36
+
func NewOAuth(db *db.DB, config *appview.Config) *OAuth {
37
+
return &OAuth{
38
+
Store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
39
+
Db: db,
40
+
Config: config,
41
+
}
42
+
}
43
+
44
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) error {
45
+
// first we save the did in the user session
46
+
userSession, err := o.Store.Get(r, appview.SessionName)
47
+
if err != nil {
48
+
return err
49
+
}
50
+
51
+
userSession.Values[appview.SessionDid] = oreq.Did
52
+
userSession.Values[appview.SessionHandle] = oreq.Handle
53
+
userSession.Values[appview.SessionPds] = oreq.PdsUrl
54
+
userSession.Values[appview.SessionAuthenticated] = true
55
+
err = userSession.Save(r, w)
56
+
if err != nil {
57
+
return fmt.Errorf("error saving user session: %w", err)
58
+
}
59
+
60
+
// then save the whole thing in the db
61
+
session := db.OAuthSession{
62
+
Did: oreq.Did,
63
+
Handle: oreq.Handle,
64
+
PdsUrl: oreq.PdsUrl,
65
+
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
66
+
AuthServerIss: oreq.AuthserverIss,
67
+
DpopPrivateJwk: oreq.DpopPrivateJwk,
68
+
AccessJwt: oresp.AccessToken,
69
+
RefreshJwt: oresp.RefreshToken,
70
+
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
71
+
}
72
+
73
+
return db.SaveOAuthSession(o.Db, session)
74
+
}
75
+
76
+
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
77
+
userSession, err := o.Store.Get(r, appview.SessionName)
78
+
if err != nil || userSession.IsNew {
79
+
return fmt.Errorf("error getting user session (or new session?): %w", err)
80
+
}
81
+
82
+
did := userSession.Values[appview.SessionDid].(string)
83
+
84
+
err = db.DeleteOAuthSessionByDid(o.Db, did)
85
+
if err != nil {
86
+
return fmt.Errorf("error deleting oauth session: %w", err)
87
+
}
88
+
89
+
userSession.Options.MaxAge = -1
90
+
91
+
return userSession.Save(r, w)
92
+
}
93
+
94
+
func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) {
95
+
userSession, err := o.Store.Get(r, appview.SessionName)
96
+
if err != nil || userSession.IsNew {
97
+
return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err)
98
+
}
99
+
100
+
did := userSession.Values[appview.SessionDid].(string)
101
+
auth := userSession.Values[appview.SessionAuthenticated].(bool)
102
+
103
+
session, err := db.GetOAuthSessionByDid(o.Db, did)
104
+
if err != nil {
105
+
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
106
+
}
107
+
108
+
expiry, err := time.Parse(time.RFC3339, session.Expiry)
109
+
if err != nil {
110
+
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
111
+
}
112
+
if expiry.Sub(time.Now()) <= 5*time.Minute {
113
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
114
+
if err != nil {
115
+
return nil, false, err
116
+
}
117
+
118
+
self := o.ClientMetadata()
119
+
120
+
oauthClient, err := client.NewClient(
121
+
self.ClientID,
122
+
o.Config.OAuth.Jwks,
123
+
self.RedirectURIs[0],
124
+
)
125
+
126
+
if err != nil {
127
+
return nil, false, err
128
+
}
129
+
130
+
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
131
+
if err != nil {
132
+
return nil, false, err
133
+
}
134
+
135
+
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
136
+
err = db.RefreshOAuthSession(o.Db, did, resp.AccessToken, resp.RefreshToken, newExpiry)
137
+
if err != nil {
138
+
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
139
+
}
140
+
141
+
// update the current session
142
+
session.AccessJwt = resp.AccessToken
143
+
session.RefreshJwt = resp.RefreshToken
144
+
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
145
+
session.Expiry = newExpiry
146
+
}
147
+
148
+
return session, auth, nil
149
+
}
150
+
151
+
type User struct {
152
+
Handle string
153
+
Did string
154
+
Pds string
155
+
}
156
+
157
+
func (a *OAuth) GetUser(r *http.Request) *User {
158
+
clientSession, err := a.Store.Get(r, appview.SessionName)
159
+
160
+
if err != nil || clientSession.IsNew {
161
+
return nil
162
+
}
163
+
164
+
return &User{
165
+
Handle: clientSession.Values[appview.SessionHandle].(string),
166
+
Did: clientSession.Values[appview.SessionDid].(string),
167
+
Pds: clientSession.Values[appview.SessionPds].(string),
168
+
}
169
+
}
170
+
171
+
func (a *OAuth) GetDid(r *http.Request) string {
172
+
clientSession, err := a.Store.Get(r, appview.SessionName)
173
+
174
+
if err != nil || clientSession.IsNew {
175
+
return ""
176
+
}
177
+
178
+
return clientSession.Values[appview.SessionDid].(string)
179
+
}
180
+
181
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
182
+
session, auth, err := o.GetSession(r)
183
+
if err != nil {
184
+
return nil, fmt.Errorf("error getting session: %w", err)
185
+
}
186
+
if !auth {
187
+
return nil, fmt.Errorf("not authorized")
188
+
}
189
+
190
+
client := &oauth.XrpcClient{
191
+
OnDpopPdsNonceChanged: func(did, newNonce string) {
192
+
err := db.UpdateDpopPdsNonce(o.Db, did, newNonce)
193
+
if err != nil {
194
+
log.Printf("error updating dpop pds nonce: %v", err)
195
+
}
196
+
},
197
+
}
198
+
199
+
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
200
+
if err != nil {
201
+
return nil, fmt.Errorf("error parsing private jwk: %w", err)
202
+
}
203
+
204
+
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
205
+
Did: session.Did,
206
+
PdsUrl: session.PdsUrl,
207
+
DpopPdsNonce: session.PdsUrl,
208
+
AccessToken: session.AccessJwt,
209
+
Issuer: session.AuthServerIss,
210
+
DpopPrivateJwk: privateJwk,
211
+
})
212
+
213
+
return xrpcClient, nil
214
+
}
215
+
216
+
type ClientMetadata struct {
217
+
ClientID string `json:"client_id"`
218
+
ClientName string `json:"client_name"`
219
+
SubjectType string `json:"subject_type"`
220
+
ClientURI string `json:"client_uri"`
221
+
RedirectURIs []string `json:"redirect_uris"`
222
+
GrantTypes []string `json:"grant_types"`
223
+
ResponseTypes []string `json:"response_types"`
224
+
ApplicationType string `json:"application_type"`
225
+
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
226
+
JwksURI string `json:"jwks_uri"`
227
+
Scope string `json:"scope"`
228
+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
229
+
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
230
+
}
231
+
232
+
func (o *OAuth) ClientMetadata() ClientMetadata {
233
+
makeRedirectURIs := func(c string) []string {
234
+
return []string{fmt.Sprintf("%s/oauth/callback", c)}
235
+
}
236
+
237
+
clientURI := o.Config.Core.AppviewHost
238
+
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
239
+
redirectURIs := makeRedirectURIs(clientURI)
240
+
241
+
if o.Config.Core.Dev {
242
+
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
243
+
redirectURIs = makeRedirectURIs(clientURI)
244
+
245
+
query := url.Values{}
246
+
query.Add("redirect_uri", redirectURIs[0])
247
+
query.Add("scope", "atproto transition:generic")
248
+
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
249
+
}
250
+
251
+
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
252
+
253
+
return ClientMetadata{
254
+
ClientID: clientID,
255
+
ClientName: "Tangled",
256
+
SubjectType: "public",
257
+
ClientURI: clientURI,
258
+
RedirectURIs: redirectURIs,
259
+
GrantTypes: []string{"authorization_code", "refresh_token"},
260
+
ResponseTypes: []string{"code"},
261
+
ApplicationType: "web",
262
+
DpopBoundAccessTokens: true,
263
+
JwksURI: jwksURI,
264
+
Scope: "atproto transition:generic",
265
+
TokenEndpointAuthMethod: "private_key_jwt",
266
+
TokenEndpointAuthSigningAlg: "ES256",
267
+
}
268
+
}
+2
-1
appview/pages/funcmap.go
+2
-1
appview/pages/funcmap.go
···
13
13
"time"
14
14
15
15
"github.com/dustin/go-humanize"
16
+
"github.com/microcosm-cc/bluemonday"
16
17
"tangled.sh/tangled.sh/core/appview/filetree"
17
18
"tangled.sh/tangled.sh/core/appview/pages/markup"
18
19
)
···
144
145
},
145
146
"markdown": func(text string) template.HTML {
146
147
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
147
-
return template.HTML(rctx.RenderMarkdown(text))
148
+
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
148
149
},
149
150
"isNil": func(t any) bool {
150
151
// returns false for other "zero" values
+6
appview/pages/htmx.go
+6
appview/pages/htmx.go
···
15
15
w.Write([]byte(html))
16
16
}
17
17
18
+
// HxRefresh is a client-side full refresh of the page.
19
+
func (s *Pages) HxRefresh(w http.ResponseWriter) {
20
+
w.Header().Set("HX-Refresh", "true")
21
+
w.WriteHeader(http.StatusOK)
22
+
}
23
+
18
24
// HxRedirect is a full page reload with a new location.
19
25
func (s *Pages) HxRedirect(w http.ResponseWriter, location string) {
20
26
w.Header().Set("HX-Redirect", location)
+10
-5
appview/pages/markup/camo.go
+10
-5
appview/pages/markup/camo.go
···
17
17
return fmt.Sprintf("%s/%s/%s", baseURL, signature, hexURL)
18
18
}
19
19
20
-
func (rctx *RenderContext) camoImageLinkTransformer(img *ast.Image) {
20
+
func (rctx *RenderContext) camoImageLinkTransformer(dst string) string {
21
21
// don't camo on dev
22
22
if rctx.IsDev {
23
-
return
23
+
return dst
24
24
}
25
25
26
-
dst := string(img.Destination)
27
-
28
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
29
-
img.Destination = []byte(generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst))
27
+
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
30
28
}
29
+
30
+
return dst
31
+
}
32
+
33
+
func (rctx *RenderContext) camoImageLinkAstTransformer(img *ast.Image) {
34
+
dst := string(img.Destination)
35
+
img.Destination = []byte(rctx.camoImageLinkTransformer(dst))
31
36
}
+152
-21
appview/pages/markup/markdown.go
+152
-21
appview/pages/markup/markdown.go
···
3
3
4
4
import (
5
5
"bytes"
6
+
"fmt"
7
+
"io"
6
8
"net/url"
7
9
"path"
10
+
"strings"
8
11
12
+
"github.com/microcosm-cc/bluemonday"
9
13
"github.com/yuin/goldmark"
10
14
"github.com/yuin/goldmark/ast"
11
15
"github.com/yuin/goldmark/extension"
12
16
"github.com/yuin/goldmark/parser"
17
+
"github.com/yuin/goldmark/renderer/html"
13
18
"github.com/yuin/goldmark/text"
14
19
"github.com/yuin/goldmark/util"
20
+
htmlparse "golang.org/x/net/html"
21
+
15
22
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
16
23
)
17
24
···
41
48
goldmark.WithParserOptions(
42
49
parser.WithAutoHeadingID(),
43
50
),
51
+
goldmark.WithRendererOptions(html.WithUnsafe()),
44
52
)
45
53
46
54
if rctx != nil {
···
57
65
if err := md.Convert([]byte(source), &buf); err != nil {
58
66
return source
59
67
}
60
-
return buf.String()
68
+
69
+
var processed strings.Builder
70
+
if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil {
71
+
return source
72
+
}
73
+
74
+
return processed.String()
75
+
}
76
+
77
+
func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error {
78
+
node, err := htmlparse.Parse(io.MultiReader(
79
+
strings.NewReader("<html><body>"),
80
+
input,
81
+
strings.NewReader("</body></html>"),
82
+
))
83
+
if err != nil {
84
+
return fmt.Errorf("failed to parse html: %w", err)
85
+
}
86
+
87
+
if node.Type == htmlparse.DocumentNode {
88
+
node = node.FirstChild
89
+
}
90
+
91
+
visitNode(ctx, node)
92
+
93
+
newNodes := make([]*htmlparse.Node, 0, 5)
94
+
95
+
if node.Data == "html" {
96
+
node = node.FirstChild
97
+
for node != nil && node.Data != "body" {
98
+
node = node.NextSibling
99
+
}
100
+
}
101
+
if node != nil {
102
+
if node.Data == "body" {
103
+
child := node.FirstChild
104
+
for child != nil {
105
+
newNodes = append(newNodes, child)
106
+
child = child.NextSibling
107
+
}
108
+
} else {
109
+
newNodes = append(newNodes, node)
110
+
}
111
+
}
112
+
113
+
for _, node := range newNodes {
114
+
if err := htmlparse.Render(output, node); err != nil {
115
+
return fmt.Errorf("failed to render processed html: %w", err)
116
+
}
117
+
}
118
+
119
+
return nil
120
+
}
121
+
122
+
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
123
+
switch node.Type {
124
+
case htmlparse.ElementNode:
125
+
if node.Data == "img" || node.Data == "source" {
126
+
for i, attr := range node.Attr {
127
+
if attr.Key != "src" {
128
+
continue
129
+
}
130
+
131
+
camoUrl, _ := url.Parse(ctx.CamoUrl)
132
+
dstUrl, _ := url.Parse(attr.Val)
133
+
if dstUrl.Host != camoUrl.Host {
134
+
attr.Val = ctx.imageFromKnotTransformer(attr.Val)
135
+
attr.Val = ctx.camoImageLinkTransformer(attr.Val)
136
+
node.Attr[i] = attr
137
+
}
138
+
}
139
+
}
140
+
141
+
for n := node.FirstChild; n != nil; n = n.NextSibling {
142
+
visitNode(ctx, n)
143
+
}
144
+
default:
145
+
}
146
+
}
147
+
148
+
func (rctx *RenderContext) Sanitize(html string) string {
149
+
policy := bluemonday.UGCPolicy()
150
+
151
+
// video
152
+
policy.AllowElements("video")
153
+
policy.AllowAttrs("controls").OnElements("video")
154
+
policy.AllowElements("source")
155
+
policy.AllowAttrs("src", "type").OnElements("source")
156
+
157
+
// centering content
158
+
policy.AllowElements("center")
159
+
160
+
policy.AllowAttrs("align", "style", "width", "height").Globally()
161
+
policy.AllowStyles(
162
+
"margin",
163
+
"padding",
164
+
"text-align",
165
+
"font-weight",
166
+
"text-decoration",
167
+
"padding-left",
168
+
"padding-right",
169
+
"padding-top",
170
+
"padding-bottom",
171
+
"margin-left",
172
+
"margin-right",
173
+
"margin-top",
174
+
"margin-bottom",
175
+
)
176
+
return policy.Sanitize(html)
61
177
}
62
178
63
179
type MarkdownTransformer struct {
···
72
188
73
189
switch a.rctx.RendererType {
74
190
case RendererTypeRepoMarkdown:
75
-
switch n.(type) {
191
+
switch n := n.(type) {
76
192
case *ast.Link:
77
-
a.rctx.relativeLinkTransformer(n.(*ast.Link))
193
+
a.rctx.relativeLinkTransformer(n)
78
194
case *ast.Image:
79
-
a.rctx.imageFromKnotTransformer(n.(*ast.Image))
80
-
a.rctx.camoImageLinkTransformer(n.(*ast.Image))
195
+
a.rctx.imageFromKnotAstTransformer(n)
196
+
a.rctx.camoImageLinkAstTransformer(n)
81
197
}
82
-
83
198
case RendererTypeDefault:
84
-
switch n.(type) {
199
+
switch n := n.(type) {
85
200
case *ast.Image:
86
-
a.rctx.imageFromKnotTransformer(n.(*ast.Image))
87
-
a.rctx.camoImageLinkTransformer(n.(*ast.Image))
201
+
a.rctx.imageFromKnotAstTransformer(n)
202
+
a.rctx.camoImageLinkAstTransformer(n)
88
203
}
89
204
}
90
205
···
93
208
}
94
209
95
210
func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
211
+
96
212
dst := string(link.Destination)
97
213
98
214
if isAbsoluteUrl(dst) {
99
215
return
100
216
}
101
217
102
-
newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, dst)
218
+
actualPath := rctx.actualPath(dst)
219
+
220
+
newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath)
103
221
link.Destination = []byte(newPath)
104
222
}
105
223
106
-
func (rctx *RenderContext) imageFromKnotTransformer(img *ast.Image) {
107
-
dst := string(img.Destination)
108
-
224
+
func (rctx *RenderContext) imageFromKnotTransformer(dst string) string {
109
225
if isAbsoluteUrl(dst) {
110
-
return
111
-
}
112
-
113
-
// strip leading './'
114
-
if len(dst) >= 2 && dst[0:2] == "./" {
115
-
dst = dst[2:]
226
+
return dst
116
227
}
117
228
118
229
scheme := "https"
119
230
if rctx.IsDev {
120
231
scheme = "http"
121
232
}
233
+
234
+
actualPath := rctx.actualPath(dst)
235
+
122
236
parsedURL := &url.URL{
123
237
Scheme: scheme,
124
238
Host: rctx.Knot,
···
127
241
rctx.RepoInfo.Name,
128
242
"raw",
129
243
url.PathEscape(rctx.RepoInfo.Ref),
130
-
dst),
244
+
actualPath),
131
245
}
132
246
newPath := parsedURL.String()
133
-
img.Destination = []byte(newPath)
247
+
return newPath
248
+
}
249
+
250
+
func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) {
251
+
dst := string(img.Destination)
252
+
img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
253
+
}
254
+
255
+
// actualPath decides when to join the file path with the
256
+
// current repository directory (essentially only when the link
257
+
// destination is relative. if it's absolute then we assume the
258
+
// user knows what they're doing.)
259
+
func (rctx *RenderContext) actualPath(dst string) string {
260
+
if path.IsAbs(dst) {
261
+
return dst
262
+
}
263
+
264
+
return path.Join(rctx.CurrentDir, dst)
134
265
}
135
266
136
267
func isAbsoluteUrl(link string) bool {
+104
-59
appview/pages/pages.go
+104
-59
appview/pages/pages.go
···
16
16
"strings"
17
17
18
18
"tangled.sh/tangled.sh/core/appview"
19
-
"tangled.sh/tangled.sh/core/appview/auth"
20
19
"tangled.sh/tangled.sh/core/appview/db"
20
+
"tangled.sh/tangled.sh/core/appview/oauth"
21
21
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
22
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
23
"tangled.sh/tangled.sh/core/appview/pagination"
···
48
48
func NewPages(config *appview.Config) *Pages {
49
49
// initialized with safe defaults, can be overriden per use
50
50
rctx := &markup.RenderContext{
51
-
IsDev: config.Dev,
52
-
CamoUrl: config.CamoHost,
53
-
CamoSecret: config.CamoSharedSecret,
51
+
IsDev: config.Core.Dev,
52
+
CamoUrl: config.Camo.Host,
53
+
CamoSecret: config.Camo.SharedSecret,
54
54
}
55
55
56
56
p := &Pages{
57
57
t: make(map[string]*template.Template),
58
-
dev: config.Dev,
58
+
dev: config.Core.Dev,
59
59
embedFS: Files,
60
60
rctx: rctx,
61
61
templateDir: "appview/pages",
···
250
250
}
251
251
252
252
type TimelineParams struct {
253
-
LoggedInUser *auth.User
253
+
LoggedInUser *oauth.User
254
254
Timeline []db.TimelineEvent
255
255
DidHandleMap map[string]string
256
256
}
···
260
260
}
261
261
262
262
type SettingsParams struct {
263
-
LoggedInUser *auth.User
263
+
LoggedInUser *oauth.User
264
264
PubKeys []db.PublicKey
265
265
Emails []db.Email
266
266
}
···
270
270
}
271
271
272
272
type KnotsParams struct {
273
-
LoggedInUser *auth.User
273
+
LoggedInUser *oauth.User
274
274
Registrations []db.Registration
275
275
}
276
276
···
279
279
}
280
280
281
281
type KnotParams struct {
282
-
LoggedInUser *auth.User
282
+
LoggedInUser *oauth.User
283
283
DidHandleMap map[string]string
284
284
Registration *db.Registration
285
285
Members []string
···
291
291
}
292
292
293
293
type NewRepoParams struct {
294
-
LoggedInUser *auth.User
294
+
LoggedInUser *oauth.User
295
295
Knots []string
296
296
}
297
297
···
300
300
}
301
301
302
302
type ForkRepoParams struct {
303
-
LoggedInUser *auth.User
303
+
LoggedInUser *oauth.User
304
304
Knots []string
305
305
RepoInfo repoinfo.RepoInfo
306
306
}
···
310
310
}
311
311
312
312
type ProfilePageParams struct {
313
-
LoggedInUser *auth.User
314
-
UserDid string
315
-
UserHandle string
313
+
LoggedInUser *oauth.User
316
314
Repos []db.Repo
317
315
CollaboratingRepos []db.Repo
318
-
ProfileStats ProfileStats
319
-
FollowStatus db.FollowStatus
320
-
AvatarUri string
321
316
ProfileTimeline *db.ProfileTimeline
317
+
Card ProfileCard
322
318
323
319
DidHandleMap map[string]string
324
320
}
325
321
326
-
type ProfileStats struct {
327
-
Followers int
328
-
Following int
322
+
type ProfileCard struct {
323
+
UserDid string
324
+
UserHandle string
325
+
FollowStatus db.FollowStatus
326
+
AvatarUri string
327
+
Followers int
328
+
Following int
329
+
330
+
Profile *db.Profile
329
331
}
330
332
331
333
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
332
334
return p.execute("user/profile", w, params)
333
335
}
334
336
337
+
type ReposPageParams struct {
338
+
LoggedInUser *oauth.User
339
+
Repos []db.Repo
340
+
Card ProfileCard
341
+
342
+
DidHandleMap map[string]string
343
+
}
344
+
345
+
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
346
+
return p.execute("user/repos", w, params)
347
+
}
348
+
335
349
type FollowFragmentParams struct {
336
350
UserDid string
337
351
FollowStatus db.FollowStatus
···
341
355
return p.executePlain("user/fragments/follow", w, params)
342
356
}
343
357
358
+
type EditBioParams struct {
359
+
LoggedInUser *oauth.User
360
+
Profile *db.Profile
361
+
}
362
+
363
+
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
364
+
return p.executePlain("user/fragments/editBio", w, params)
365
+
}
366
+
367
+
type EditPinsParams struct {
368
+
LoggedInUser *oauth.User
369
+
Profile *db.Profile
370
+
AllRepos []PinnedRepo
371
+
DidHandleMap map[string]string
372
+
}
373
+
374
+
type PinnedRepo struct {
375
+
IsPinned bool
376
+
db.Repo
377
+
}
378
+
379
+
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
380
+
return p.executePlain("user/fragments/editPins", w, params)
381
+
}
382
+
344
383
type RepoActionsFragmentParams struct {
345
384
IsStarred bool
346
385
RepoAt syntax.ATURI
···
364
403
}
365
404
366
405
type RepoIndexParams struct {
367
-
LoggedInUser *auth.User
368
-
RepoInfo repoinfo.RepoInfo
369
-
Active string
370
-
TagMap map[string][]string
371
-
CommitsTrunc []*object.Commit
372
-
TagsTrunc []*types.TagReference
373
-
BranchesTrunc []types.Branch
374
-
types.RepoIndexResponse
406
+
LoggedInUser *oauth.User
407
+
RepoInfo repoinfo.RepoInfo
408
+
Active string
409
+
TagMap map[string][]string
410
+
CommitsTrunc []*object.Commit
411
+
TagsTrunc []*types.TagReference
412
+
BranchesTrunc []types.Branch
413
+
ForkInfo *types.ForkInfo
375
414
HTMLReadme template.HTML
376
415
Raw bool
377
416
EmailToDidOrHandle map[string]string
417
+
Languages *types.RepoLanguageResponse
418
+
types.RepoIndexResponse
378
419
}
379
420
380
421
func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
···
393
434
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
394
435
htmlString = p.rctx.RenderMarkdown(params.Readme)
395
436
params.Raw = false
396
-
params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
437
+
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
397
438
default:
398
439
htmlString = string(params.Readme)
399
440
params.Raw = true
···
405
446
}
406
447
407
448
type RepoLogParams struct {
408
-
LoggedInUser *auth.User
449
+
LoggedInUser *oauth.User
409
450
RepoInfo repoinfo.RepoInfo
410
451
TagMap map[string][]string
411
452
types.RepoLogResponse
···
419
460
}
420
461
421
462
type RepoCommitParams struct {
422
-
LoggedInUser *auth.User
463
+
LoggedInUser *oauth.User
423
464
RepoInfo repoinfo.RepoInfo
424
465
Active string
425
466
EmailToDidOrHandle map[string]string
···
433
474
}
434
475
435
476
type RepoTreeParams struct {
436
-
LoggedInUser *auth.User
477
+
LoggedInUser *oauth.User
437
478
RepoInfo repoinfo.RepoInfo
438
479
Active string
439
480
BreadCrumbs [][]string
···
469
510
}
470
511
471
512
type RepoBranchesParams struct {
472
-
LoggedInUser *auth.User
513
+
LoggedInUser *oauth.User
473
514
RepoInfo repoinfo.RepoInfo
474
515
Active string
475
516
types.RepoBranchesResponse
···
481
522
}
482
523
483
524
type RepoTagsParams struct {
484
-
LoggedInUser *auth.User
525
+
LoggedInUser *oauth.User
485
526
RepoInfo repoinfo.RepoInfo
486
527
Active string
487
528
types.RepoTagsResponse
···
495
536
}
496
537
497
538
type RepoArtifactParams struct {
498
-
LoggedInUser *auth.User
539
+
LoggedInUser *oauth.User
499
540
RepoInfo repoinfo.RepoInfo
500
541
Artifact db.Artifact
501
542
}
···
505
546
}
506
547
507
548
type RepoBlobParams struct {
508
-
LoggedInUser *auth.User
549
+
LoggedInUser *oauth.User
509
550
RepoInfo repoinfo.RepoInfo
510
551
Active string
511
552
BreadCrumbs [][]string
···
523
564
case markup.FormatMarkdown:
524
565
p.rctx.RepoInfo = params.RepoInfo
525
566
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
526
-
params.RenderedContents = template.HTML(p.rctx.RenderMarkdown(params.Contents))
567
+
htmlString := p.rctx.RenderMarkdown(params.Contents)
568
+
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
527
569
}
528
570
}
529
571
···
567
609
}
568
610
569
611
type RepoSettingsParams struct {
570
-
LoggedInUser *auth.User
612
+
LoggedInUser *oauth.User
571
613
RepoInfo repoinfo.RepoInfo
572
614
Collaborators []Collaborator
573
615
Active string
574
-
Branches []string
575
-
DefaultBranch string
616
+
Branches []types.Branch
576
617
// TODO: use repoinfo.roles
577
618
IsCollaboratorInviteAllowed bool
578
619
}
···
583
624
}
584
625
585
626
type RepoIssuesParams struct {
586
-
LoggedInUser *auth.User
627
+
LoggedInUser *oauth.User
587
628
RepoInfo repoinfo.RepoInfo
588
629
Active string
589
630
Issues []db.Issue
···
598
639
}
599
640
600
641
type RepoSingleIssueParams struct {
601
-
LoggedInUser *auth.User
642
+
LoggedInUser *oauth.User
602
643
RepoInfo repoinfo.RepoInfo
603
644
Active string
604
645
Issue db.Issue
···
620
661
}
621
662
622
663
type RepoNewIssueParams struct {
623
-
LoggedInUser *auth.User
664
+
LoggedInUser *oauth.User
624
665
RepoInfo repoinfo.RepoInfo
625
666
Active string
626
667
}
···
631
672
}
632
673
633
674
type EditIssueCommentParams struct {
634
-
LoggedInUser *auth.User
675
+
LoggedInUser *oauth.User
635
676
RepoInfo repoinfo.RepoInfo
636
677
Issue *db.Issue
637
678
Comment *db.Comment
···
642
683
}
643
684
644
685
type SingleIssueCommentParams struct {
645
-
LoggedInUser *auth.User
686
+
LoggedInUser *oauth.User
646
687
DidHandleMap map[string]string
647
688
RepoInfo repoinfo.RepoInfo
648
689
Issue *db.Issue
···
654
695
}
655
696
656
697
type RepoNewPullParams struct {
657
-
LoggedInUser *auth.User
698
+
LoggedInUser *oauth.User
658
699
RepoInfo repoinfo.RepoInfo
659
700
Branches []types.Branch
660
701
Active string
···
666
707
}
667
708
668
709
type RepoPullsParams struct {
669
-
LoggedInUser *auth.User
710
+
LoggedInUser *oauth.User
670
711
RepoInfo repoinfo.RepoInfo
671
712
Pulls []*db.Pull
672
713
Active string
···
698
739
}
699
740
700
741
type RepoSinglePullParams struct {
701
-
LoggedInUser *auth.User
702
-
RepoInfo repoinfo.RepoInfo
703
-
Active string
704
-
DidHandleMap map[string]string
705
-
Pull *db.Pull
706
-
MergeCheck types.MergeCheckResponse
707
-
ResubmitCheck ResubmitResult
742
+
LoggedInUser *oauth.User
743
+
RepoInfo repoinfo.RepoInfo
744
+
Active string
745
+
DidHandleMap map[string]string
746
+
Pull *db.Pull
747
+
Stack db.Stack
748
+
AbandonedPulls []*db.Pull
749
+
MergeCheck types.MergeCheckResponse
750
+
ResubmitCheck ResubmitResult
708
751
}
709
752
710
753
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
713
756
}
714
757
715
758
type RepoPullPatchParams struct {
716
-
LoggedInUser *auth.User
759
+
LoggedInUser *oauth.User
717
760
DidHandleMap map[string]string
718
761
RepoInfo repoinfo.RepoInfo
719
762
Pull *db.Pull
763
+
Stack db.Stack
720
764
Diff *types.NiceDiff
721
765
Round int
722
766
Submission *db.PullSubmission
···
728
772
}
729
773
730
774
type RepoPullInterdiffParams struct {
731
-
LoggedInUser *auth.User
775
+
LoggedInUser *oauth.User
732
776
DidHandleMap map[string]string
733
777
RepoInfo repoinfo.RepoInfo
734
778
Pull *db.Pull
···
778
822
}
779
823
780
824
type PullResubmitParams struct {
781
-
LoggedInUser *auth.User
825
+
LoggedInUser *oauth.User
782
826
RepoInfo repoinfo.RepoInfo
783
827
Pull *db.Pull
784
828
SubmissionId int
···
789
833
}
790
834
791
835
type PullActionsParams struct {
792
-
LoggedInUser *auth.User
836
+
LoggedInUser *oauth.User
793
837
RepoInfo repoinfo.RepoInfo
794
838
Pull *db.Pull
795
839
RoundNumber int
796
840
MergeCheck types.MergeCheckResponse
797
841
ResubmitCheck ResubmitResult
842
+
Stack db.Stack
798
843
}
799
844
800
845
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
802
847
}
803
848
804
849
type PullNewCommentParams struct {
805
-
LoggedInUser *auth.User
850
+
LoggedInUser *oauth.User
806
851
RepoInfo repoinfo.RepoInfo
807
852
Pull *db.Pull
808
853
RoundNumber int
+1
appview/pages/repoinfo/repoinfo.go
+1
appview/pages/repoinfo/repoinfo.go
-21
appview/pages/templates/index.html
-21
appview/pages/templates/index.html
···
1
-
<html>
2
-
{{ template "layouts/head" . }}
3
-
4
-
<header>
5
-
<h1>{{ .meta.Title }}</h1>
6
-
<h2>{{ .meta.Description }}</h2>
7
-
</header>
8
-
<body>
9
-
<main>
10
-
<div class="index">
11
-
{{ range .info }}
12
-
<div class="index-name">
13
-
<a href="/{{ .Name }}">{{ .DisplayName }}</a>
14
-
</div>
15
-
<div class="desc">{{ .Desc }}</div>
16
-
<div>{{ .Idle }}</div>
17
-
{{ end }}
18
-
</div>
19
-
</main>
20
-
</body>
21
-
</html>
+11
-5
appview/pages/templates/knots.html
+11
-5
appview/pages/templates/knots.html
···
10
10
<form
11
11
hx-post="/knots/key"
12
12
class="max-w-2xl mb-8 space-y-4"
13
+
hx-indicator="#generate-knot-key-spinner"
13
14
>
14
15
<input
15
16
type="text"
···
18
19
placeholder="knot.example.com"
19
20
required
20
21
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
21
-
/>
22
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">
23
-
generate key
22
+
>
23
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
24
+
<span>generate key</span>
25
+
<span id="generate-knot-key-spinner" class="group">
26
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
+
</span>
24
28
</button>
25
29
<div id="settings-knots-error" class="error dark:text-red-400"></div>
26
30
</form>
···
70
74
</div>
71
75
<div class="flex gap-2 items-center">
72
76
<button
73
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 gap-2"
74
-
hx-post="/knots/{{ .Domain }}/init">
77
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
78
+
hx-post="/knots/{{ .Domain }}/init"
79
+
>
75
80
{{ i "square-play" "w-5 h-5" }}
76
81
<span class="hidden md:inline">initialize</span>
82
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
77
83
</button>
78
84
</div>
79
85
</div>
+1
appview/pages/templates/layouts/base.html
+1
appview/pages/templates/layouts/base.html
···
7
7
name="viewport"
8
8
content="width=device-width, initial-scale=1.0"
9
9
/>
10
+
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
10
11
<script src="/static/htmx.min.js"></script>
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
+1
-1
appview/pages/templates/layouts/repobase.html
+1
-1
appview/pages/templates/layouts/repobase.html
···
61
61
</div>
62
62
</nav>
63
63
<section
64
-
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
64
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"
65
65
>
66
66
{{ block "repoContent" . }}{{ end }}
67
67
</section>
+13
appview/pages/templates/layouts/topbar.html
+13
appview/pages/templates/layouts/topbar.html
···
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
8
</div>
9
+
<div class="hidden md:flex gap-4 items-center">
10
+
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
+
{{ i "message-circle" "size-4" }} discord
12
+
</a>
13
+
14
+
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
+
{{ i "hash" "size-4" }} irc
16
+
</a>
17
+
18
+
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
+
{{ i "code" "size-4" }} source
20
+
</a>
21
+
</div>
9
22
<div id="right-items" class="flex gap-2">
10
23
{{ with .LoggedInUser }}
11
24
<a href="/repo/new" hx-boost="true">
+7
-8
appview/pages/templates/repo/blob.html
+7
-8
appview/pages/templates/repo/blob.html
···
1
1
{{ define "title" }}{{ .Path }} at {{ .Ref }} · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
-
4
3
{{ define "extrameta" }}
5
-
<meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/>
6
-
<meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}">
7
-
<meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}">
8
-
<meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}">
9
-
<meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}">
10
-
<meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}">
4
+
{{ template "repo/fragments/meta" . }}
5
+
6
+
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
+
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
+
9
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
+
11
11
{{ end }}
12
-
13
12
14
13
{{ define "repoContent" }}
15
14
{{ $lines := split .Contents }}
+10
-3
appview/pages/templates/repo/branches.html
+10
-3
appview/pages/templates/repo/branches.html
···
1
1
{{ define "title" }}
2
-
branches ยท {{ .RepoInfo.FullName }}
2
+
branches · {{ .RepoInfo.FullName }}
3
+
{{ end }}
4
+
5
+
{{ define "extrameta" }}
6
+
{{ $title := printf "branches · %s" .RepoInfo.FullName }}
7
+
{{ $url := printf "https://tangled.sh/%s/branches" .RepoInfo.FullName }}
8
+
9
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
3
10
{{ end }}
4
11
5
12
{{ define "repoContent" }}
···
52
59
</td>
53
60
<td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
54
61
{{ if .Commit }}
55
-
{{ .Commit.Author.When | timeFmt }}
62
+
{{ .Commit.Committer.When | timeFmt }}
56
63
{{ end }}
57
64
</td>
58
65
</tr>
···
91
98
</a>
92
99
</span>
93
100
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
94
-
<span>{{ .Commit.Author.When | timeFmt }}</span>
101
+
<span>{{ .Commit.Committer.When | timeFmt }}</span>
95
102
</div>
96
103
{{ end }}
97
104
</div>
+8
appview/pages/templates/repo/commit.html
+8
appview/pages/templates/repo/commit.html
···
1
1
{{ define "title" }} commit {{ .Diff.Commit.This }} · {{ .RepoInfo.FullName }} {{ end }}
2
2
3
+
{{ define "extrameta" }}
4
+
{{ $title := printf "commit %s · %s" .Diff.Commit.This .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.sh/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
6
+
7
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
+
{{ end }}
9
+
10
+
3
11
{{ define "repoContent" }}
4
12
5
13
{{ $repo := .RepoInfo.FullName }}
+26
-3
appview/pages/templates/repo/empty.html
+26
-3
appview/pages/templates/repo/empty.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
+
{{ define "extrameta" }}
4
+
{{ template "repo/fragments/meta" . }}
5
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
6
+
{{ end }}
7
+
3
8
{{ define "repoContent" }}
4
-
<main>
9
+
<main>
10
+
{{ if gt (len .BranchesTrunc) 0 }}
11
+
<div class="flex flex-col items-center">
5
12
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
6
-
This is an empty repository. Push some commits here.
13
+
This branch is empty. Other branches in this repository are populated:
7
14
</p>
8
-
</main>
15
+
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
16
+
{{ range $br := .BranchesTrunc }}
17
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name}}" class="no-underline hover:no-underline">
18
+
<div class="flex items-center justify-between p-2">
19
+
{{ $br.Name }}
20
+
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
21
+
</div>
22
+
</a>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ else }}
27
+
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
28
+
This is an empty repository. Push some commits here.
29
+
</p>
30
+
{{ end }}
31
+
</main>
9
32
{{ end }}
10
33
11
34
{{ define "repoAfter" }}
+1
-1
appview/pages/templates/repo/fragments/cloneInstructions.html
+1
-1
appview/pages/templates/repo/fragments/cloneInstructions.html
···
4
4
{{ $knot = "tangled.sh" }}
5
5
{{ end }}
6
6
<section
7
-
class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
7
+
class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
8
8
>
9
9
<div class="flex flex-col gap-2">
10
10
<strong>push</strong>
+6
-6
appview/pages/templates/repo/fragments/filetree.html
+6
-6
appview/pages/templates/repo/fragments/filetree.html
···
2
2
{{ if and .Name .IsDirectory }}
3
3
<details open>
4
4
<summary class="cursor-pointer list-none pt-1">
5
-
<span class="inline-flex items-center gap-2 ">
6
-
{{ i "folder" "w-3 h-3 fill-current" }}
7
-
<span class="text-black dark:text-white">{{ .Name }}</span>
5
+
<span class="tree-directory inline-flex items-center gap-2 ">
6
+
{{ i "folder" "size-4 fill-current" }}
7
+
<span class="filename text-black dark:text-white">{{ .Name }}</span>
8
8
</span>
9
9
</summary>
10
10
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
···
14
14
</div>
15
15
</details>
16
16
{{ else if .Name }}
17
-
<div class="flex items-center gap-2 pt-1">
18
-
{{ i "file" "w-3 h-3" }}
19
-
<a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
17
+
<div class="tree-file flex items-center gap-2 pt-1">
18
+
{{ i "file" "size-4" }}
19
+
<a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
20
20
</div>
21
21
{{ else }}
22
22
{{ range $child := .Children }}
+26
appview/pages/templates/repo/fragments/meta.html
+26
appview/pages/templates/repo/fragments/meta.html
···
1
+
{{ define "repo/fragments/meta" }}
2
+
<meta
3
+
name="vcs:clone"
4
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
5
+
/>
6
+
<meta
7
+
name="forge:summary"
8
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
9
+
/>
10
+
<meta
11
+
name="forge:dir"
12
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
13
+
/>
14
+
<meta
15
+
name="forge:file"
16
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
17
+
/>
18
+
<meta
19
+
name="forge:line"
20
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
21
+
/>
22
+
<meta
23
+
name="go-import"
24
+
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
25
+
/>
26
+
{{ end }}
+11
appview/pages/templates/repo/fragments/og.html
+11
appview/pages/templates/repo/fragments/og.html
···
1
+
{{ define "repo/fragments/og" }}
2
+
{{ $title := or .Title .RepoInfo.FullName }}
3
+
{{ $description := or .Description .RepoInfo.Description }}
4
+
{{ $url := or .Url (printf "https://tangled.sh/%s" .RepoInfo.FullName) }}
5
+
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
{{ end }}
+13
-12
appview/pages/templates/repo/fragments/repoActions.html
+13
-12
appview/pages/templates/repo/fragments/repoActions.html
···
2
2
<div class="flex items-center gap-2 z-auto">
3
3
<button
4
4
id="starBtn"
5
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed"
5
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
6
{{ if .IsStarred }}
7
7
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
8
8
{{ else }}
···
14
14
hx-swap="outerHTML"
15
15
hx-disabled-elt="#starBtn"
16
16
>
17
-
<div class="flex gap-2 items-center">
18
-
{{ if .IsStarred }}
19
-
{{ i "star" "w-4 h-4 fill-current" }}
20
-
{{ else }}
21
-
{{ i "star" "w-4 h-4" }}
22
-
{{ end }}
23
-
<span class="text-sm">
24
-
{{ .Stats.StarCount }}
25
-
</span>
26
-
</div>
17
+
{{ if .IsStarred }}
18
+
{{ i "star" "w-4 h-4 fill-current" }}
19
+
{{ else }}
20
+
{{ i "star" "w-4 h-4" }}
21
+
{{ end }}
22
+
<span class="text-sm">
23
+
{{ .Stats.StarCount }}
24
+
</span>
25
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
26
</button>
28
27
{{ if .DisableFork }}
29
28
<button
···
36
35
</button>
37
36
{{ else }}
38
37
<a
39
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2"
38
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
39
+
hx-boost="true"
40
40
href="/{{ .FullName }}/fork"
41
41
>
42
42
{{ i "git-fork" "w-4 h-4" }}
43
43
fork
44
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
44
45
</a>
45
46
{{ end }}
46
47
</div>
+82
-71
appview/pages/templates/repo/index.html
+82
-71
appview/pages/templates/repo/index.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }}
2
2
3
+
3
4
{{ define "extrameta" }}
4
-
<meta
5
-
name="vcs:clone"
6
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
7
-
/>
8
-
<meta
9
-
name="forge:summary"
10
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
11
-
/>
12
-
<meta
13
-
name="forge:dir"
14
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
15
-
/>
16
-
<meta
17
-
name="forge:file"
18
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
19
-
/>
20
-
<meta
21
-
name="forge:line"
22
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
23
-
/>
24
-
<meta
25
-
name="go-import"
26
-
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
27
-
/>
5
+
{{ template "repo/fragments/meta" . }}
6
+
7
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
28
8
{{ end }}
9
+
29
10
30
11
{{ define "repoContent" }}
31
12
<main>
···
51
32
{{ end }}
52
33
53
34
{{ define "branchSelector" }}
54
-
<select
55
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
56
-
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
57
-
>
58
-
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
59
-
{{ range .Branches }}
60
-
<option
61
-
value="{{ .Reference.Name }}"
62
-
class="py-1"
63
-
{{ if eq .Reference.Name $.Ref }}
64
-
selected
65
-
{{ end }}
66
-
>
67
-
{{ .Reference.Name }}
68
-
</option>
69
-
{{ end }}
70
-
</optgroup>
71
-
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
72
-
{{ range .Tags }}
73
-
<option
74
-
value="{{ .Reference.Name }}"
75
-
class="py-1"
76
-
{{ if eq .Reference.Name $.Ref }}
77
-
selected
78
-
{{ end }}
79
-
>
80
-
{{ .Reference.Name }}
81
-
</option>
35
+
<div class="flex gap-4 items-center justify-center">
36
+
<select
37
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
38
+
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
39
+
>
40
+
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
41
+
{{ range .Branches }}
42
+
<option
43
+
value="{{ .Reference.Name }}"
44
+
class="py-1"
45
+
{{ if eq .Reference.Name $.Ref }}
46
+
selected
47
+
{{ end }}
48
+
>
49
+
{{ .Reference.Name }}
50
+
</option>
51
+
{{ end }}
52
+
</optgroup>
53
+
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
54
+
{{ range .Tags }}
55
+
<option
56
+
value="{{ .Reference.Name }}"
57
+
class="py-1"
58
+
{{ if eq .Reference.Name $.Ref }}
59
+
selected
60
+
{{ end }}
61
+
>
62
+
{{ .Reference.Name }}
63
+
</option>
64
+
{{ else }}
65
+
<option class="py-1" disabled>no tags found</option>
66
+
{{ end }}
67
+
</optgroup>
68
+
</select>
69
+
{{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }}
70
+
{{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }}
71
+
{{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }}
72
+
{{ $disabled := "" }}
73
+
{{ $title := "" }}
74
+
{{ if eq .ForkInfo.Status 0 }}
75
+
{{ $disabled = "disabled" }}
76
+
{{ $title = "This branch is not behind the upstream" }}
77
+
{{ else if eq .ForkInfo.Status 2 }}
78
+
{{ $disabled = "disabled" }}
79
+
{{ $title = "This branch has conflicts that must be resolved" }}
80
+
{{ else if eq .ForkInfo.Status 3 }}
81
+
{{ $disabled = "disabled" }}
82
+
{{ $title = "This branch does not exist on the upstream" }}
83
+
{{ end }}
84
+
85
+
<button
86
+
id="syncBtn"
87
+
{{ $disabled }}
88
+
{{ if $title }}title="{{ $title }}"{{ end }}
89
+
class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed"
90
+
hx-post="/{{ .RepoInfo.FullName }}/fork/sync"
91
+
hx-trigger="click"
92
+
hx-swap="none"
93
+
>
94
+
{{ if $disabled }}
95
+
{{ i "refresh-cw-off" "w-4 h-4" }}
82
96
{{ else }}
83
-
<option class="py-1" disabled>no tags found</option>
97
+
{{ i "refresh-cw" "w-4 h-4" }}
84
98
{{ end }}
85
-
</optgroup>
86
-
</select>
99
+
<span>sync</span>
100
+
</button>
101
+
{{ end }}
102
+
</div>
87
103
{{ end }}
88
104
89
105
{{ define "fileTree" }}
···
103
119
class="{{ $linkstyle }}"
104
120
>
105
121
<div class="flex items-center gap-2">
106
-
{{ i "folder" "w-3 h-3 fill-current" }}
122
+
{{ i "folder" "size-4 fill-current" }}
107
123
{{ .Name }}
108
124
</div>
109
125
</a>
···
125
141
class="{{ $linkstyle }}"
126
142
>
127
143
<div class="flex items-center gap-2">
128
-
{{ i "file" "w-3 h-3" }}{{ .Name }}
144
+
{{ i "file" "size-4" }}{{ .Name }}
129
145
</div>
130
146
</a>
131
147
···
223
239
<div
224
240
class="inline-block px-1 select-none after:content-['ยท']"
225
241
></div>
226
-
<span>{{ timeFmt .Author.When }}</span>
242
+
<span>{{ timeFmt .Committer.When }}</span>
227
243
{{ $tagsForCommit := index $.TagMap .Hash.String }}
228
244
{{ if gt (len $tagsForCommit) 0 }}
229
245
<div
···
264
280
</a>
265
281
{{ if .Commit }}
266
282
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
267
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Author.When }}</time>
283
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time>
268
284
{{ end }}
269
285
{{ if .IsDefault }}
270
286
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
···
318
334
{{ end }}
319
335
320
336
{{ define "repoAfter" }}
321
-
{{- if .HTMLReadme }}
337
+
{{- if .HTMLReadme -}}
322
338
<section
323
-
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }}
339
+
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
324
340
prose dark:prose-invert dark:[&_pre]:bg-gray-900
325
341
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
326
342
dark:[&_pre]:border dark:[&_pre]:border-gray-700
327
343
{{ end }}"
328
344
>
329
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">
330
-
{{ if .Raw }}
331
-
<pre
332
-
class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded"
333
-
>
334
-
{{ .HTMLReadme }}</pre
335
-
>
336
-
{{ else }}
345
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll">
346
+
{{- .HTMLReadme -}}
347
+
</pre>
348
+
{{- else -}}
337
349
{{ .HTMLReadme }}
338
-
{{ end }}
339
-
</article>
350
+
{{- end -}}</article>
340
351
</section>
341
352
{{- end -}}
342
353
+2
-1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+2
-1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
23
23
</a>
24
24
25
25
<button
26
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
26
+
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
27
27
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
28
28
hx-include="#edit-textarea-{{ .CommentId }}"
29
29
hx-target="#comment-container-{{ .CommentId }}"
30
30
hx-swap="outerHTML">
31
31
{{ i "check" "w-4 h-4" }}
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
33
</button>
33
34
<button
34
35
class="btn px-2 py-1 flex items-center gap-2 text-sm"
+2
-1
appview/pages/templates/repo/issues/fragments/issueComment.html
+2
-1
appview/pages/templates/repo/issues/fragments/issueComment.html
···
38
38
{{ i "pencil" "w-4 h-4" }}
39
39
</button>
40
40
<button
41
-
class="btn px-2 py-1 text-sm text-red-500"
41
+
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
42
42
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
43
hx-confirm="Are you sure you want to delete your comment?"
44
44
hx-swap="outerHTML"
45
45
hx-target="#comment-container-{{.CommentId}}"
46
46
>
47
47
{{ i "trash-2" "w-4 h-4" }}
48
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
48
49
</button>
49
50
{{ end }}
50
51
+38
-8
appview/pages/templates/repo/issues/issue.html
+38
-8
appview/pages/templates/repo/issues/issue.html
···
1
1
{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
+
4
+
{{ define "extrameta" }}
5
+
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
+
{{ $url := printf "https://tangled.sh/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
+
8
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
9
+
{{ end }}
10
+
3
11
{{ define "repoContent" }}
4
12
<header class="pb-4">
5
13
<h1 class="text-2xl">
···
85
93
86
94
<div class="flex gap-2 mt-2">
87
95
<button
88
-
id="comment-button"
89
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
90
-
type="submit"
96
+
id="comment-button"
97
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
98
+
type="submit"
91
99
hx-disabled-elt="#comment-button"
92
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"
100
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"
93
101
disabled
94
102
>
95
103
{{ i "message-square-plus" "w-4 h-4" }}
96
104
comment
105
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
97
106
</button>
98
107
99
108
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
100
109
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
101
-
{{ if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "open") }}
110
+
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
111
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
102
112
<button
103
113
id="close-button"
104
114
type="button"
105
115
class="btn flex items-center gap-2"
116
+
hx-indicator="#close-spinner"
106
117
hx-trigger="click"
107
118
>
108
119
{{ i "ban" "w-4 h-4" }}
109
120
close
121
+
<span id="close-spinner" class="group">
122
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
123
+
</span>
110
124
</button>
111
125
<div
112
126
id="close-with-comment"
···
114
128
hx-trigger="click from:#close-button"
115
129
hx-disabled-elt="#close-with-comment"
116
130
hx-target="#issue-comment"
131
+
hx-indicator="#close-spinner"
117
132
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
118
133
hx-swap="none"
119
134
>
···
124
139
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
125
140
hx-trigger="click from:#close-button"
126
141
hx-target="#issue-action"
142
+
hx-indicator="#close-spinner"
127
143
hx-swap="none"
128
144
>
129
145
</div>
···
138
154
}
139
155
});
140
156
</script>
141
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "closed") }}
157
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
142
158
<button
143
159
type="button"
144
160
class="btn flex items-center gap-2"
145
161
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
162
+
hx-indicator="#reopen-spinner"
146
163
hx-swap="none"
147
164
>
148
165
{{ i "refresh-ccw-dot" "w-4 h-4" }}
149
166
reopen
167
+
<span id="reopen-spinner" class="group">
168
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
169
+
</span>
150
170
</button>
151
171
{{ end }}
152
172
···
164
184
165
185
if (closeButton) {
166
186
if (textarea.value.trim() !== '') {
167
-
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close with comment';
187
+
closeButton.innerHTML = `
188
+
{{ i "ban" "w-4 h-4" }}
189
+
<span>close with comment</span>
190
+
<span id="close-spinner" class="group">
191
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
192
+
</span>`;
168
193
} else {
169
-
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close';
194
+
closeButton.innerHTML = `
195
+
{{ i "ban" "w-4 h-4" }}
196
+
<span>close</span>
197
+
<span id="close-spinner" class="group">
198
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
199
+
</span>`;
170
200
}
171
201
}
172
202
}
+33
-17
appview/pages/templates/repo/issues/issues.html
+33
-17
appview/pages/templates/repo/issues/issues.html
···
1
1
{{ define "title" }}issues · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
+
{{ define "extrameta" }}
4
+
{{ $title := "issues"}}
5
+
{{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }}
6
+
7
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
+
{{ end }}
9
+
3
10
{{ define "repoContent" }}
4
-
<div class="flex justify-between items-center">
5
-
<p>
6
-
filtering
7
-
<select class="border p-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value">
8
-
<option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option>
9
-
<option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option>
10
-
</select>
11
-
issues
12
-
</p>
13
-
<a
14
-
href="/{{ .RepoInfo.FullName }}/issues/new"
15
-
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline">
16
-
{{ i "circle-plus" "w-4 h-4" }}
17
-
<span>new</span>
18
-
</a>
19
-
</div>
20
-
<div class="error" id="issues"></div>
11
+
<div class="flex justify-between items-center gap-4">
12
+
<div class="flex gap-4">
13
+
<a
14
+
href="?state=open"
15
+
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
16
+
>
17
+
{{ i "circle-dot" "w-4 h-4" }}
18
+
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
19
+
</a>
20
+
<a
21
+
href="?state=closed"
22
+
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
+
>
24
+
{{ i "ban" "w-4 h-4" }}
25
+
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
26
+
</a>
27
+
</div>
28
+
<a
29
+
href="/{{ .RepoInfo.FullName }}/issues/new"
30
+
class="btn text-sm flex items-center justify-center gap-2 no-underline hover:no-underline"
31
+
>
32
+
{{ i "circle-plus" "w-4 h-4" }}
33
+
<span>new</span>
34
+
</a>
35
+
</div>
36
+
<div class="error" id="issues"></div>
21
37
{{ end }}
22
38
23
39
{{ define "repoAfter" }}
+7
-1
appview/pages/templates/repo/issues/new.html
+7
-1
appview/pages/templates/repo/issues/new.html
···
5
5
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
6
class="mt-6 space-y-6"
7
7
hx-swap="none"
8
+
hx-indicator="#spinner"
8
9
>
9
10
<div class="flex flex-col gap-4">
10
11
<div>
···
22
23
></textarea>
23
24
</div>
24
25
<div>
25
-
<button type="submit" class="btn">create</button>
26
+
<button type="submit" class="btn flex items-center gap-2">
27
+
create
28
+
<span id="spinner" class="group">
29
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
30
+
</span>
31
+
</button>
26
32
</div>
27
33
</div>
28
34
<div id="issues" class="error"></div>
+9
-2
appview/pages/templates/repo/log.html
+9
-2
appview/pages/templates/repo/log.html
···
1
1
{{ define "title" }}commits · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
+
{{ define "extrameta" }}
4
+
{{ $title := printf "commits · %s" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.sh/%s/commits" .RepoInfo.FullName }}
6
+
7
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
+
{{ end }}
9
+
3
10
{{ define "repoContent" }}
4
11
<section id="commit-table" class="overflow-x-auto">
5
12
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
···
64
71
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
65
72
{{ end }}
66
73
</td>
67
-
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Author.When }}</td>
74
+
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td>
68
75
</tr>
69
76
{{ end }}
70
77
</tbody>
···
134
141
</a>
135
142
</span>
136
143
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
137
-
<span>{{ shortTimeFmt $commit.Author.When }}</span>
144
+
<span>{{ shortTimeFmt $commit.Committer.When }}</span>
138
145
</div>
139
146
</div>
140
147
{{ end }}
+7
-2
appview/pages/templates/repo/new.html
+7
-2
appview/pages/templates/repo/new.html
···
5
5
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none">
8
+
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
9
<div class="space-y-2">
10
10
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
11
<input
···
60
60
</fieldset>
61
61
62
62
<div class="space-y-2">
63
-
<button type="submit" class="btn">create repo</button>
63
+
<button type="submit" class="btn flex gap-2 items-center">
64
+
create repo
65
+
<span id="spinner" class="group">
66
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
67
+
</span>
68
+
</button>
64
69
<div id="repo" class="error"></div>
65
70
</div>
66
71
</form>
+25
-9
appview/pages/templates/repo/pulls/fragments/pullActions.html
+25
-9
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
1
1
{{ define "repo/pulls/fragments/pullActions" }}
2
2
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
3
3
{{ $roundNumber := .RoundNumber }}
4
+
{{ $stack := .Stack }}
5
+
6
+
{{ $totalPulls := sub 0 1 }}
7
+
{{ $below := sub 0 1 }}
8
+
{{ $stackCount := "" }}
9
+
{{ if .Pull.IsStacked }}
10
+
{{ $totalPulls = len $stack }}
11
+
{{ $below = $stack.Below .Pull }}
12
+
{{ $mergeable := len $below.Mergeable }}
13
+
{{ $stackCount = printf "%d/%d" $mergeable $totalPulls }}
14
+
{{ end }}
4
15
5
16
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
6
17
{{ $isMerged := .Pull.State.IsMerged }}
···
17
28
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
18
29
hx-target="#actions-{{$roundNumber}}"
19
30
hx-swap="outerHtml"
20
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
31
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
21
32
{{ i "message-square-plus" "w-4 h-4" }}
22
33
<span>comment</span>
34
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
23
35
</button>
24
36
{{ if and $isPushAllowed $isOpen $isLastRound }}
25
37
{{ $disabled := "" }}
···
30
42
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
31
43
hx-swap="none"
32
44
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
33
-
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
45
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
34
46
{{ i "git-merge" "w-4 h-4" }}
35
-
<span>merge</span>
47
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
48
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
36
49
</button>
37
50
{{ end }}
38
51
···
51
64
{{ end }}
52
65
53
66
hx-disabled-elt="#resubmitBtn"
54
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }}
67
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
55
68
56
69
{{ if $disabled }}
57
70
title="Update this branch to resubmit this pull request"
···
59
72
title="Resubmit this pull request"
60
73
{{ end }}
61
74
>
62
-
{{ i "rotate-ccw" "w-4 h-4" }}
75
+
{{ i "rotate-ccw" "w-4 h-4" }}
63
76
<span>resubmit</span>
77
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
64
78
</button>
65
79
{{ end }}
66
80
···
68
82
<button
69
83
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
70
84
hx-swap="none"
71
-
class="btn p-2 flex items-center gap-2">
72
-
{{ i "ban" "w-4 h-4" }}
85
+
class="btn p-2 flex items-center gap-2 group">
86
+
{{ i "ban" "w-4 h-4" }}
73
87
<span>close</span>
88
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
89
</button>
75
90
{{ end }}
76
91
···
78
93
<button
79
94
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
80
95
hx-swap="none"
81
-
class="btn p-2 flex items-center gap-2">
82
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
96
+
class="btn p-2 flex items-center gap-2 group">
97
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
83
98
<span>reopen</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
84
100
</button>
85
101
{{ end }}
86
102
</div>
+17
-4
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
+17
-4
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
1
1
{{ define "repo/pulls/fragments/pullCompareBranches" }}
2
2
<div id="patch-upload">
3
-
<label for="targetBranch" class="dark:text-white"
4
-
>select a branch</label
5
-
>
3
+
<label for="targetBranch" class="dark:text-white">select a branch</label>
6
4
<div class="flex flex-wrap gap-2 items-center">
7
5
<select
8
6
name="sourceBranch"
9
7
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
10
8
>
11
9
<option disabled selected>source branch</option>
10
+
11
+
{{ $recent := index .Branches 0 }}
12
12
{{ range .Branches }}
13
-
<option value="{{ .Reference.Name }}" class="py-1">
13
+
{{ $isRecent := eq .Reference.Name $recent.Reference.Name }}
14
+
<option
15
+
value="{{ .Reference.Name }}"
16
+
{{ if $isRecent }}
17
+
selected
18
+
{{ end }}
19
+
class="py-1"
20
+
>
14
21
{{ .Reference.Name }}
22
+
{{ if $isRecent }}(new){{ end }}
15
23
</option>
16
24
{{ end }}
17
25
</select>
18
26
</div>
27
+
</div>
28
+
29
+
<div class="flex items-center gap-2">
30
+
<input type="checkbox" id="isStacked" name="isStacked" value="on">
31
+
<label for="isStacked" class="my-0 py-0 normal-case font-normal">Submit as stacked PRs</label>
19
32
</div>
20
33
21
34
<p class="mt-4">
+7
-1
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+7
-1
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
3
3
<label for="forkSelect" class="dark:text-white"
4
4
>select a fork to compare</label
5
5
>
6
-
<div class="flex flex-wrap gap-4 items-center mb-4">
6
+
<div class="flex flex-wrap gap-4 items-center">
7
7
<div class="flex flex-wrap gap-2 items-center">
8
8
<select
9
9
id="forkSelect"
···
39
39
</div>
40
40
</div>
41
41
</div>
42
+
43
+
<div class="flex items-center gap-2">
44
+
<input type="checkbox" id="isStacked" name="isStacked" value="on">
45
+
<label for="isStacked" class="my-0 py-0 normal-case font-normal">Submit as stacked PRs</label>
46
+
</div>
47
+
42
48
<p class="mt-4">
43
49
Title and description are optional; if left out, they will be extracted
44
50
from the first commit.
+11
-1
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
+11
-1
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
5
5
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
6
6
>
7
7
<option disabled selected>source branch</option>
8
+
9
+
{{ $recent := index .SourceBranches 0 }}
8
10
{{ range .SourceBranches }}
9
-
<option value="{{ .Reference.Name }}" class="py-1">
11
+
{{ $isRecent := eq .Reference.Name $recent.Reference.Name }}
12
+
<option
13
+
value="{{ .Reference.Name }}"
14
+
{{ if $isRecent }}
15
+
selected
16
+
{{ end }}
17
+
class="py-1"
18
+
>
10
19
{{ .Reference.Name }}
20
+
{{ if $isRecent }}(new){{ end }}
11
21
</option>
12
22
{{ end }}
13
23
</select>
+10
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+10
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
34
34
>
35
35
<span class="select-none before:content-['\00B7']"></span>
36
36
<time>{{ .Pull.Created | timeFmt }}</time>
37
+
37
38
<span class="select-none before:content-['\00B7']"></span>
38
39
<span>
39
40
targeting
···
42
43
</span>
43
44
</span>
44
45
{{ if not .Pull.IsPatchBased }}
45
-
<span>from
46
-
{{ if .Pull.IsForkBased }}
47
-
{{ if .Pull.PullSource.Repo }}
48
-
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>
49
-
{{ else }}
50
-
<span class="italic">[deleted fork]</span>
51
-
{{ end }}
52
-
{{ end }}
53
-
46
+
from
54
47
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
55
-
{{ .Pull.PullSource.Branch }}
48
+
{{ if .Pull.IsForkBased }}
49
+
{{ if .Pull.PullSource.Repo }}
50
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
51
+
{{- else -}}
52
+
<span class="italic">[deleted fork]</span>
53
+
{{- end -}}
54
+
{{- end -}}
55
+
{{- .Pull.PullSource.Branch -}}
56
56
</span>
57
-
</span>
58
57
{{ end }}
59
58
</span>
60
59
</div>
+16
-7
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+16
-7
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
10
+
hx-indicator="#create-comment-spinner"
10
11
hx-swap="none"
11
-
class="w-full flex flex-wrap gap-2">
12
+
class="w-full flex flex-wrap gap-2"
13
+
>
12
14
<textarea
13
15
name="body"
14
16
class="w-full p-2 rounded border border-gray-200"
15
-
placeholder="Add to the discussion..."></textarea>
17
+
placeholder="Add to the discussion..."></textarea
18
+
>
16
19
<button type="submit" class="btn flex items-center gap-2">
17
-
{{ i "message-square" "w-4 h-4" }} comment
20
+
{{ i "message-square" "w-4 h-4" }}
21
+
<span>comment</span>
22
+
<span id="create-comment-spinner" class="group">
23
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
24
+
</span>
18
25
</button>
19
-
<button
20
-
type="button"
21
-
class="btn flex items-center gap-2"
26
+
<button
27
+
type="button"
28
+
class="btn flex items-center gap-2 group"
22
29
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
23
30
hx-swap="outerHTML"
24
-
hx-target="#pull-comment-card-{{ .RoundNumber }}">
31
+
hx-target="#pull-comment-card-{{ .RoundNumber }}"
32
+
>
25
33
{{ i "x" "w-4 h-4" }}
26
34
<span>cancel</span>
35
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
36
</button>
28
37
<div id="pull-comment"></div>
29
38
</form>
+20
-7
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
+20
-7
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
···
18
18
<form
19
19
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
20
20
hx-swap="none"
21
-
class="w-full flex flex-wrap gap-2">
21
+
class="w-full flex flex-wrap gap-2"
22
+
hx-indicator="#resubmit-spinner"
23
+
>
22
24
<textarea
23
25
name="patch"
24
26
class="w-full p-2 mb-2"
25
27
placeholder="Paste your updated patch here."
26
28
rows="15"
27
-
>{{.Pull.LatestPatch}}</textarea>
29
+
>
30
+
{{.Pull.LatestPatch}}
31
+
</textarea>
28
32
<button
29
33
type="submit"
30
34
class="btn flex items-center gap-2"
31
35
{{ if or .Pull.State.IsClosed }}
32
36
disabled
33
-
{{ end }}>
37
+
{{ end }}
38
+
>
34
39
{{ i "rotate-ccw" "w-4 h-4" }}
35
40
<span>resubmit</span>
41
+
<span id="resubmit-spinner" class="group">
42
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
43
+
</span>
36
44
</button>
37
-
<button
38
-
type="button"
39
-
class="btn flex items-center gap-2"
45
+
<button
46
+
type="button"
47
+
class="btn flex items-center gap-2"
40
48
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
41
49
hx-swap="outerHTML"
42
-
hx-target="#resubmit-pull-card">
50
+
hx-target="#resubmit-pull-card"
51
+
hx-indicator="#cancel-resubmit-spinner"
52
+
>
43
53
{{ i "x" "w-4 h-4" }}
44
54
<span>cancel</span>
55
+
<span id="cancel-resubmit-spinner" class="group">
56
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
57
+
</span>
45
58
</button>
46
59
</form>
47
60
+86
appview/pages/templates/repo/pulls/fragments/pullStack.html
+86
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
1
+
{{ define "repo/pulls/fragments/pullStack" }}
2
+
<p class="text-sm font-bold p-2 dark:text-white">STACK</p>
3
+
{{ block "pullList" (list .Stack $) }} {{ end }}
4
+
5
+
{{ if gt (len .AbandonedPulls) 0 }}
6
+
<p class="text-sm font-bold p-2 dark:text-white">ABANDONED PULLS</p>
7
+
{{ block "pullList" (list .AbandonedPulls $) }} {{ end }}
8
+
{{ end }}
9
+
{{ end }}
10
+
11
+
{{ define "summarizedHeader" }}
12
+
<div class="flex text-sm items-center justify-between w-full">
13
+
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
14
+
<div class="flex-shrink-0">
15
+
{{ block "summarizedPullState" . }} {{ end }}
16
+
</div>
17
+
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
18
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
19
+
{{ .Title }}
20
+
</span>
21
+
</div>
22
+
23
+
<div class="flex-shrink-0">
24
+
{{ $latestRound := .LastRoundNumber }}
25
+
{{ $lastSubmission := index .Submissions $latestRound }}
26
+
{{ $commentCount := len $lastSubmission.Comments }}
27
+
<span>
28
+
<div class="inline-flex items-center gap-2">
29
+
{{ i "message-square" "w-3 h-3 md:hidden" }}
30
+
{{ $commentCount }}
31
+
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
32
+
</div>
33
+
</span>
34
+
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
35
+
<span>
36
+
<span class="hidden md:inline">round</span>
37
+
<span class="font-mono">#{{ $latestRound }}</span>
38
+
</span>
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
43
+
{{ define "summarizedPullState" }}
44
+
{{ $fgColor := "text-gray-600 dark:text-gray-300" }}
45
+
{{ $icon := "ban" }}
46
+
47
+
{{ if .State.IsOpen }}
48
+
{{ $fgColor = "text-green-600 dark:text-green-500" }}
49
+
{{ $icon = "git-pull-request" }}
50
+
{{ else if .State.IsMerged }}
51
+
{{ $fgColor = "text-purple-600 dark:text-purple-500" }}
52
+
{{ $icon = "git-merge" }}
53
+
{{ else if .State.IsDeleted }}
54
+
{{ $fgColor = "text-red-600 dark:text-red-500" }}
55
+
{{ $icon = "git-pull-request-closed" }}
56
+
{{ end }}
57
+
58
+
{{ $style := printf "w-4 h-4 %s" $fgColor }}
59
+
60
+
{{ i $icon $style }}
61
+
{{ end }}
62
+
63
+
{{ define "pullList" }}
64
+
{{ $list := index . 0 }}
65
+
{{ $root := index . 1 }}
66
+
<div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
67
+
{{ range $pull := $list }}
68
+
{{ $isCurrent := false }}
69
+
{{ with $root.Pull }}
70
+
{{ $isCurrent = eq $pull.PullId $root.Pull.PullId }}
71
+
{{ end }}
72
+
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
73
+
<div class="flex gap-2 items-center px-2 {{ if $isCurrent }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
74
+
{{ if $isCurrent }}
75
+
<div class="flex-shrink-0">
76
+
{{ i "arrow-right" "w-4 h-4" }}
77
+
</div>
78
+
{{ end }}
79
+
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
80
+
{{ block "summarizedHeader" $pull }} {{ end }}
81
+
</div>
82
+
</div>
83
+
</a>
84
+
{{ end }}
85
+
</div>
86
+
{{ end }}
+9
-1
appview/pages/templates/repo/pulls/interdiff.html
+9
-1
appview/pages/templates/repo/pulls/interdiff.html
···
1
1
{{ define "title" }}
2
-
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
2
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
+
{{ end }}
4
+
5
+
6
+
{{ define "extrameta" }}
7
+
{{ $title := printf "interdiff of %d and %d · %s · pull #%d · %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
+
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
+
10
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }}
3
11
{{ end }}
4
12
5
13
{{ define "content" }}
+63
-61
appview/pages/templates/repo/pulls/new.html
+63
-61
appview/pages/templates/repo/pulls/new.html
···
3
3
{{ define "repoContent" }}
4
4
<form
5
5
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
6
-
class="mt-6 space-y-6"
6
+
hx-indicator="#create-pull-spinner"
7
7
hx-swap="none"
8
8
>
9
-
<div class="flex flex-col gap-4">
10
-
<label>configure your pull request</label>
11
-
12
-
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
13
-
<div class="pb-2">
9
+
<div class="flex flex-col gap-6">
10
+
<div class="flex gap-2 items-center">
11
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p>
12
+
<div>
14
13
<select
15
-
required
16
-
name="targetBranch"
17
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
18
-
>
19
-
<option disabled selected>target branch</option>
20
-
{{ range .Branches }}
21
-
<option value="{{ .Reference.Name }}" class="py-1">
22
-
{{ .Reference.Name }}
23
-
</option>
24
-
{{ end }}
14
+
required
15
+
name="targetBranch"
16
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
+
>
18
+
<option disabled selected>target branch</option>
19
+
{{ range .Branches }}
20
+
<option value="{{ .Reference.Name }}" class="py-1" {{if .IsDefault}}selected{{end}}>
21
+
{{ .Reference.Name }}
22
+
</option>
23
+
{{ end }}
25
24
</select>
25
+
</div>
26
26
</div>
27
27
28
-
<p>Next, choose a pull strategy.</p>
29
-
<nav class="flex space-x-4 items-end">
30
-
<button
31
-
type="button"
32
-
class="px-3 py-2 pb-2 btn"
33
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
34
-
hx-target="#patch-strategy"
35
-
hx-swap="innerHTML"
36
-
>
37
-
paste patch
38
-
</button>
39
-
40
-
{{ if .RepoInfo.Roles.IsPushAllowed }}
41
-
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
42
-
or
43
-
</span>
44
-
<button
45
-
type="button"
46
-
class="px-3 py-2 pb-2 btn"
47
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
48
-
hx-target="#patch-strategy"
49
-
hx-swap="innerHTML"
50
-
>
51
-
compare branches
52
-
</button>
53
-
{{ end }}
54
-
28
+
<div class="flex flex-col gap-2">
29
+
<p>Next, choose a pull strategy.</p>
30
+
<nav class="flex space-x-4 items-center">
31
+
<button
32
+
type="button"
33
+
class="btn"
34
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
35
+
hx-target="#patch-strategy"
36
+
hx-swap="innerHTML"
37
+
>
38
+
paste patch
39
+
</button>
55
40
56
-
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
57
-
or
58
-
</span>
59
-
<button
60
-
type="button"
61
-
class="px-3 py-2 pb-2 btn"
62
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
63
-
hx-target="#patch-strategy"
64
-
hx-swap="innerHTML"
65
-
>
66
-
compare forks
67
-
</button>
68
-
</nav>
41
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
42
+
<span class="text-sm text-gray-500 dark:text-gray-400">
43
+
or
44
+
</span>
45
+
<button
46
+
type="button"
47
+
class="btn"
48
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
49
+
hx-target="#patch-strategy"
50
+
hx-swap="innerHTML"
51
+
>
52
+
compare branches
53
+
</button>
54
+
{{ end }}
69
55
70
-
<section id="patch-strategy">
71
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
72
-
</section>
73
56
74
-
<p id="patch-preview"></p>
57
+
<span class="text-sm text-gray-500 dark:text-gray-400">
58
+
or
59
+
</span>
60
+
<button
61
+
type="button"
62
+
class="btn"
63
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
64
+
hx-target="#patch-strategy"
65
+
hx-swap="innerHTML"
66
+
>
67
+
compare forks
68
+
</button>
69
+
</nav>
70
+
<section id="patch-strategy" class="flex flex-col gap-2">
71
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
72
+
</section>
75
73
76
-
<div id="patch-error" class="error dark:text-red-300"></div>
74
+
<div id="patch-error" class="error dark:text-red-300"></div>
75
+
</div>
77
76
78
77
<div>
79
78
<label for="title" class="dark:text-white">write a title</label>
···
105
104
<button type="submit" class="btn flex items-center gap-2">
106
105
{{ i "git-pull-request-create" "w-4 h-4" }}
107
106
create pull
107
+
<span id="create-pull-spinner" class="group">
108
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
109
+
</span>
108
110
</button>
109
111
</div>
110
112
</div>
+10
-1
appview/pages/templates/repo/pulls/patch.html
+10
-1
appview/pages/templates/repo/pulls/patch.html
···
2
2
patch of {{ .Pull.Title }} · round #{{ .Round }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
3
{{ end }}
4
4
5
+
6
+
{{ define "extrameta" }}
7
+
{{ $title := printf "patch of %s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
+
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
+
10
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
11
+
{{ end }}
12
+
13
+
5
14
{{ define "content" }}
6
15
<section>
7
16
<section
8
-
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
17
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"
9
18
>
10
19
<div class="flex gap-3 items-center mb-3">
11
20
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
+48
-16
appview/pages/templates/repo/pulls/pull.html
+48
-16
appview/pages/templates/repo/pulls/pull.html
···
2
2
{{ .Pull.Title }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
3
{{ end }}
4
4
5
+
{{ define "extrameta" }}
6
+
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
+
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
+
9
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
+
{{ end }}
11
+
12
+
5
13
{{ define "repoContent" }}
6
-
{{ template "repo/pulls/fragments/pullHeader" . }}
14
+
{{ template "repo/pulls/fragments/pullHeader" . }}
15
+
16
+
{{ if .Pull.IsStacked }}
17
+
<div class="mt-8">
18
+
{{ template "repo/pulls/fragments/pullStack" . }}
19
+
</div>
20
+
{{ end }}
7
21
{{ end }}
8
22
9
23
{{ define "repoAfter" }}
···
51
65
</span>
52
66
</div>
53
67
54
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
68
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
55
69
hx-boost="true"
56
70
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
57
-
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
71
+
{{ i "file-diff" "w-4 h-4" }}
72
+
<span class="hidden md:inline">diff</span>
73
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
74
</a>
59
75
{{ if not (eq .RoundNumber 0) }}
60
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
76
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
61
77
hx-boost="true"
62
78
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
63
-
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
79
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
+
<span class="hidden md:inline">interdiff</span>
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
64
82
</a>
65
83
<span id="interdiff-error-{{.RoundNumber}}"></span>
66
84
{{ end }}
67
85
</div>
68
86
</summary>
69
-
87
+
70
88
{{ if .IsFormatPatch }}
71
89
{{ $patches := .AsFormatPatch }}
72
90
{{ $round := .RoundNumber }}
···
150
168
{{ end }}
151
169
152
170
{{ if $.LoggedInUser }}
153
-
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }}
171
+
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
154
172
{{ else }}
155
173
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
156
174
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
···
181
199
>
182
200
</div>
183
201
</div>
202
+
{{ else if .Pull.State.IsDeleted }}
203
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
204
+
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
205
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
206
+
<span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span>
207
+
</div>
208
+
</div>
184
209
{{ else if and .MergeCheck .MergeCheck.Error }}
185
210
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
186
211
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
···
195
220
{{ i "triangle-alert" "w-4 h-4" }}
196
221
<span class="font-medium">merge conflicts detected</span>
197
222
</div>
198
-
<ul class="space-y-1">
199
-
{{ range .MergeCheck.Conflicts }}
200
-
{{ if .Filename }}
201
-
<li class="flex items-center">
202
-
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
203
-
<span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span>
204
-
</li>
223
+
{{ if gt (len .MergeCheck.Conflicts) 0 }}
224
+
<ul class="space-y-1">
225
+
{{ range .MergeCheck.Conflicts }}
226
+
{{ if .Filename }}
227
+
<li class="flex items-center">
228
+
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
229
+
<span class="font-mono">{{ .Filename }}</span>
230
+
</li>
231
+
{{ else if .Reason }}
232
+
<li class="flex items-center">
233
+
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
234
+
<span>{{.Reason}}</span>
235
+
</li>
236
+
{{ end }}
205
237
{{ end }}
206
-
{{ end }}
207
-
</ul>
238
+
</ul>
239
+
{{ end }}
208
240
</div>
209
241
</div>
210
242
{{ else if .MergeCheck }}
+39
-29
appview/pages/templates/repo/pulls/pulls.html
+39
-29
appview/pages/templates/repo/pulls/pulls.html
···
1
1
{{ define "title" }}pulls · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
+
{{ define "extrameta" }}
4
+
{{ $title := "pulls"}}
5
+
{{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }}
6
+
7
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
+
{{ end }}
9
+
3
10
{{ define "repoContent" }}
4
11
<div class="flex justify-between items-center">
5
-
<p class="dark:text-white">
6
-
filtering
7
-
<select
8
-
class="border p-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white"
9
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value"
12
+
<div class="flex gap-4">
13
+
<a
14
+
href="?state=open"
15
+
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
10
16
>
11
-
<option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}>
12
-
open ({{ .RepoInfo.Stats.PullCount.Open }})
13
-
</option>
14
-
<option value="merged" {{ if .FilteringBy.IsMerged }}selected{{ end }}>
15
-
merged ({{ .RepoInfo.Stats.PullCount.Merged }})
16
-
</option>
17
-
<option value="closed" {{ if .FilteringBy.IsClosed }}selected{{ end }}>
18
-
closed ({{ .RepoInfo.Stats.PullCount.Closed }})
19
-
</option>
20
-
</select>
21
-
pull requests
22
-
</p>
17
+
{{ i "git-pull-request" "w-4 h-4" }}
18
+
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
19
+
</a>
20
+
<a
21
+
href="?state=merged"
22
+
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
23
+
>
24
+
{{ i "git-merge" "w-4 h-4" }}
25
+
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
26
+
</a>
27
+
<a
28
+
href="?state=closed"
29
+
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
30
+
>
31
+
{{ i "ban" "w-4 h-4" }}
32
+
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
33
+
</a>
34
+
</div>
23
35
<a
24
36
href="/{{ .RepoInfo.FullName }}/pulls/new"
25
37
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"
···
79
91
</span>
80
92
</span>
81
93
{{ if not .IsPatchBased }}
82
-
<span>from
83
-
{{ if .IsForkBased }}
84
-
{{ if .PullSource.Repo }}
85
-
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>
86
-
{{ else }}
87
-
<span class="italic">[deleted fork]</span>
88
-
{{ end }}
89
-
{{ end }}
90
-
91
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
92
-
{{ .PullSource.Branch }}
93
-
</span>
94
+
from
95
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
96
+
{{ if .IsForkBased }}
97
+
{{ if .PullSource.Repo }}
98
+
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>:
99
+
{{- else -}}
100
+
<span class="italic">[deleted fork]</span>
101
+
{{- end -}}
102
+
{{- end -}}
103
+
{{- .PullSource.Branch -}}
94
104
</span>
95
105
{{ end }}
96
106
<span class="before:content-['ยท']">
+50
-21
appview/pages/templates/repo/settings.html
+50
-21
appview/pages/templates/repo/settings.html
···
23
23
</div>
24
24
25
25
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
27
-
<label for="collaborator" class="dark:text-white"
28
-
>add collaborator</label
29
-
>
26
+
<form
27
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
+
class="group"
29
+
>
30
+
<label for="collaborator" class="dark:text-white">
31
+
add collaborator
32
+
</label>
30
33
<input
31
34
type="text"
32
35
id="collaborator"
···
34
37
required
35
38
class="dark:bg-gray-700 dark:text-white"
36
39
placeholder="enter did or handle"
37
-
/>
40
+
>
38
41
<button
39
-
class="btn my-2 dark:text-white dark:hover:bg-gray-700"
42
+
class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700"
40
43
type="text"
41
44
>
42
-
add
45
+
<span>add</span>
46
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
43
47
</button>
44
48
</form>
45
49
{{ end }}
46
50
47
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6">
51
+
<form
52
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
+
class="mt-6 group"
54
+
>
48
55
<label for="branch">default branch</label>
49
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
50
-
{{ range .Branches }}
56
+
<div class="flex gap-2 items-center">
57
+
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
51
58
<option
52
-
value="{{ . }}"
53
-
class="py-1"
54
-
{{ if eq . $.DefaultBranch }}
55
-
selected
56
-
{{ end }}
59
+
value=""
60
+
disabled
61
+
selected
57
62
>
58
-
{{ . }}
63
+
Choose a default branch
59
64
</option>
60
-
{{ end }}
61
-
</select>
62
-
<button class="btn my-2" type="text">save</button>
65
+
{{ range .Branches }}
66
+
<option
67
+
value="{{ .Name }}"
68
+
class="py-1"
69
+
{{ if .IsDefault }}
70
+
selected
71
+
{{ end }}
72
+
>
73
+
{{ .Name }}
74
+
</option>
75
+
{{ end }}
76
+
</select>
77
+
<button class="btn my-2 flex gap-2 items-center" type="submit">
78
+
<span>save</span>
79
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
80
+
</button>
81
+
</div>
63
82
</form>
64
83
65
84
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
66
-
<form hx-confirm="Are you sure you want to delete this repository?" hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" class="mt-6">
85
+
<form
86
+
hx-confirm="Are you sure you want to delete this repository?"
87
+
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
88
+
class="mt-6"
89
+
hx-indicator="#delete-repo-spinner"
90
+
>
67
91
<label for="branch">delete repository</label>
68
-
<button class="btn my-2" type="text">delete</button>
92
+
<button class="btn my-2 flex gap-2 items-center" type="text">
93
+
<span>delete</span>
94
+
<span id="delete-repo-spinner" class="group">
95
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
96
+
</span>
97
+
</button>
69
98
<span>
70
99
Deleting a repository is irreversible and permanent.
71
100
</span>
+13
-8
appview/pages/templates/repo/tree.html
+13
-8
appview/pages/templates/repo/tree.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
5
-
<meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/>
6
-
<meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}">
7
-
<meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}">
8
-
<meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}">
9
-
<meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}">
10
-
<meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}">
5
+
6
+
{{ $path := "" }}
7
+
{{ range .BreadCrumbs }}
8
+
{{ $path = printf "%s/%s" $path (index . 0) }}
9
+
{{ end }}
10
+
11
+
{{ template "repo/fragments/meta" . }}
12
+
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
+
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
+
15
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
11
16
{{ end }}
12
17
13
18
···
54
59
<div class="flex justify-between items-center">
55
60
<a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}">
56
61
<div class="flex items-center gap-2">
57
-
{{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }}
62
+
{{ i "folder" "size-4 fill-current" }}{{ .Name }}
58
63
</div>
59
64
</a>
60
65
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
···
69
74
<div class="flex justify-between items-center">
70
75
<a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}">
71
76
<div class="flex items-center gap-2">
72
-
{{ i "file" "w-3 h-3" }}{{ .Name }}
77
+
{{ i "file" "size-4" }}{{ .Name }}
73
78
</div>
74
79
</a>
75
80
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
+35
-10
appview/pages/templates/settings.html
+35
-10
appview/pages/templates/settings.html
···
45
45
</div>
46
46
</div>
47
47
<button
48
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2"
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
49
title="Delete key"
50
50
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
-
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?">
51
+
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"
52
+
>
52
53
{{ i "trash-2" "w-5 h-5" }}
53
54
<span class="hidden md:inline">delete</span>
55
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
54
56
</button>
55
57
</div>
56
58
{{ end }}
57
59
</div>
58
60
<form
59
61
hx-put="/settings/keys"
62
+
hx-indicator="#add-sshkey-spinner"
60
63
hx-swap="none"
61
64
class="max-w-2xl mb-8 space-y-4"
62
65
>
···
75
78
required
76
79
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
77
80
78
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add key</button>
81
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
82
+
<span>add key</span>
83
+
<span id="add-sshkey-spinner" class="group">
84
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
85
+
</span>
86
+
</button>
79
87
80
88
<div id="settings-keys" class="error dark:text-red-400"></div>
81
89
</form>
···
129
137
</a>
130
138
{{ end }}
131
139
{{ if not .Primary }}
132
-
<form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?">
140
+
<form
141
+
hx-delete="/settings/emails"
142
+
hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"
143
+
hx-indicator="#delete-email-{{ .Address }}-spinner"
144
+
>
133
145
<input type="hidden" name="email" value="{{ .Address }}">
134
146
<button
135
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
147
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
136
148
title="Delete email"
137
-
type="submit">
149
+
type="submit"
150
+
>
138
151
{{ i "trash-2" "w-5 h-5" }}
139
152
<span class="hidden md:inline">delete</span>
153
+
<span id="delete-email-{{ .Address }}-spinner" class="group">
154
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
155
+
</span>
140
156
</button>
141
157
</form>
142
158
{{ end }}
···
148
164
hx-put="/settings/emails"
149
165
hx-swap="none"
150
166
class="max-w-2xl mb-8 space-y-4"
167
+
hx-indicator="#add-email-spinner"
151
168
>
152
169
<input
153
170
type="email"
···
155
172
name="email"
156
173
placeholder="your@email.com"
157
174
required
158
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
175
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
176
+
>
159
177
160
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add email</button>
178
+
<button
179
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center"
180
+
type="submit"
181
+
>
182
+
<span>add email</span>
183
+
<span id="add-email-spinner" class="group">
184
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
185
+
</span>
186
+
</button>
161
187
162
188
<div id="settings-emails-error" class="error dark:text-red-400"></div>
163
189
<div id="settings-emails-success" class="success dark:text-green-400"></div>
164
-
165
190
</form>
166
191
</section>
167
-
{{ end }}
192
+
{{ end }}
+130
-72
appview/pages/templates/timeline.html
+130
-72
appview/pages/templates/timeline.html
···
1
1
{{ define "title" }}timeline{{ end }}
2
2
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="timeline ยท tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.sh" />
7
+
<meta property="og:description" content="see what's tangling" />
8
+
{{ end }}
9
+
3
10
{{ define "topbar" }}
4
-
{{ with .LoggedInUser }}
5
-
{{ template "layouts/topbar" $ }}
6
-
{{ else }}
7
-
{{ end }}
11
+
{{ template "layouts/topbar" $ }}
8
12
{{ end }}
9
13
10
14
{{ define "content" }}
11
-
{{ with .LoggedInUser }}
12
-
{{ block "timeline" $ }} {{ end }}
13
-
{{ else }}
14
-
{{ block "hero" $ }} {{ end }}
15
-
{{ block "timeline" $ }} {{ end }}
16
-
{{ end }}
15
+
{{ with .LoggedInUser }}
16
+
{{ block "timeline" $ }}{{ end }}
17
+
{{ else }}
18
+
{{ block "hero" $ }}{{ end }}
19
+
{{ block "timeline" $ }}{{ end }}
20
+
{{ end }}
17
21
{{ end }}
18
22
19
23
{{ define "hero" }}
20
-
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white dark:bg-gray-800 text-black dark:text-white py-4 px-10">
21
-
<div class="font-bold italic text-4xl mb-4">
22
-
tangled
23
-
</div>
24
-
<div class="italic text-lg">
25
-
tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a>
26
-
<p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a>or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
27
-
Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p>
28
-
</div>
29
-
</div>
24
+
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
25
+
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
26
+
27
+
<p class="text-lg">
28
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
29
+
</p>
30
+
<p class="text-lg">
31
+
we envision a place where developers have complete ownership of their
32
+
code, open source communities can freely self-govern and most
33
+
importantly, coding can be social and fun again.
34
+
</p>
35
+
36
+
<div class="flex gap-6 items-center">
37
+
<a href="/login" class="no-underline hover:no-underline ">
38
+
<button class="btn flex gap-2 px-4 items-center">
39
+
join now {{ i "arrow-right" "size-4" }}
40
+
</button>
41
+
</a>
42
+
</div>
43
+
</div>
30
44
{{ end }}
31
45
32
46
{{ define "timeline" }}
33
-
<div>
34
-
<div class="p-6">
35
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
36
-
</div>
37
-
38
-
<div class="flex flex-col gap-3 relative">
39
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div>
40
-
{{ range .Timeline }}
41
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit">
42
-
{{ if .Repo }}
43
-
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
44
-
<div class="flex items-center">
45
-
<p class="text-gray-600 dark:text-gray-300">
46
-
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
47
-
{{ if .Source }}
48
-
forked
49
-
<a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">
50
-
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}
51
-
</a>
52
-
to
53
-
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
54
-
{{ else }}
55
-
created
56
-
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
57
-
{{ end }}
58
-
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time>
59
-
</p>
47
+
<div>
48
+
<div class="p-6">
49
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
60
50
</div>
61
-
{{ else if .Follow }}
62
-
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
63
-
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
64
-
<div class="flex items-center">
65
-
<p class="text-gray-600 dark:text-gray-300">
66
-
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
67
-
followed
68
-
<a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a>
69
-
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
70
-
</p>
71
-
</div>
72
-
{{ else if .Star }}
73
-
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
74
-
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
75
-
<div class="flex items-center">
76
-
<p class="text-gray-600 dark:text-gray-300">
77
-
<a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a>
78
-
starred
79
-
<a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a>
80
-
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Star.Created | timeFmt }}</time>
81
-
</p>
51
+
52
+
<div class="flex flex-col gap-3 relative">
53
+
<div
54
+
class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"
55
+
></div>
56
+
{{ range .Timeline }}
57
+
<div
58
+
class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit"
59
+
>
60
+
{{ if .Repo }}
61
+
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
62
+
<div class="flex items-center">
63
+
<p class="text-gray-600 dark:text-gray-300">
64
+
<a
65
+
href="/{{ $userHandle }}"
66
+
class="no-underline hover:underline"
67
+
>{{ $userHandle | truncateAt30 }}</a
68
+
>
69
+
{{ if .Source }}
70
+
forked
71
+
<a
72
+
href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}"
73
+
class="no-underline hover:underline"
74
+
>
75
+
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}
76
+
</a>
77
+
to
78
+
<a
79
+
href="/{{ $userHandle }}/{{ .Repo.Name }}"
80
+
class="no-underline hover:underline"
81
+
>{{ .Repo.Name }}</a
82
+
>
83
+
{{ else }}
84
+
created
85
+
<a
86
+
href="/{{ $userHandle }}/{{ .Repo.Name }}"
87
+
class="no-underline hover:underline"
88
+
>{{ .Repo.Name }}</a
89
+
>
90
+
{{ end }}
91
+
<time
92
+
class="text-gray-700 dark:text-gray-400 text-xs"
93
+
>{{ .Repo.Created | timeFmt }}</time
94
+
>
95
+
</p>
96
+
</div>
97
+
{{ else if .Follow }}
98
+
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
99
+
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
100
+
<div class="flex items-center">
101
+
<p class="text-gray-600 dark:text-gray-300">
102
+
<a
103
+
href="/{{ $userHandle }}"
104
+
class="no-underline hover:underline"
105
+
>{{ $userHandle | truncateAt30 }}</a
106
+
>
107
+
followed
108
+
<a
109
+
href="/{{ $subjectHandle }}"
110
+
class="no-underline hover:underline"
111
+
>{{ $subjectHandle | truncateAt30 }}</a
112
+
>
113
+
<time
114
+
class="text-gray-700 dark:text-gray-400 text-xs"
115
+
>{{ .Follow.FollowedAt | timeFmt }}</time
116
+
>
117
+
</p>
118
+
</div>
119
+
{{ else if .Star }}
120
+
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
121
+
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
122
+
<div class="flex items-center">
123
+
<p class="text-gray-600 dark:text-gray-300">
124
+
<a
125
+
href="/{{ $starrerHandle }}"
126
+
class="no-underline hover:underline"
127
+
>{{ $starrerHandle | truncateAt30 }}</a
128
+
>
129
+
starred
130
+
<a
131
+
href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}"
132
+
class="no-underline hover:underline"
133
+
>{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a
134
+
>
135
+
<time
136
+
class="text-gray-700 dark:text-gray-400 text-xs"
137
+
>{{ .Star.Created | timeFmt }}</time
138
+
>
139
+
</p>
140
+
</div>
141
+
{{ end }}
142
+
</div>
143
+
{{ end }}
82
144
</div>
83
-
{{ end }}
84
145
</div>
85
-
{{ end }}
86
-
</div>
87
-
</div>
88
146
{{ end }}
+6
appview/pages/templates/user/fragments/bluesky.html
+6
appview/pages/templates/user/fragments/bluesky.html
···
1
+
{{ define "user/fragments/bluesky" }}
2
+
<svg class="{{.}}" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-3 -3 30 30">
3
+
<title>Bluesky</title>
4
+
<path fill="none" stroke="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" stroke-width="2.25"/>
5
+
</svg>
6
+
{{ end }}
+111
appview/pages/templates/user/fragments/editBio.html
+111
appview/pages/templates/user/fragments/editBio.html
···
1
+
{{ define "user/fragments/editBio" }}
2
+
<form
3
+
hx-post="/profile/bio"
4
+
class="flex flex-col gap-4 my-2 max-w-full"
5
+
hx-disabled-elt="#save-btn,#cancel-btn"
6
+
hx-swap="none"
7
+
hx-indicator="#spinner">
8
+
<div class="flex flex-col gap-1">
9
+
{{ $description := "" }}
10
+
{{ if and .Profile .Profile.Description }}
11
+
{{ $description = .Profile.Description }}
12
+
{{ end }}
13
+
<label class="m-0 p-0" for="description">bio</label>
14
+
<textarea
15
+
type="text"
16
+
class="py-1 px-1 w-full"
17
+
name="description"
18
+
rows="3"
19
+
placeholder="write a bio">{{ $description }}</textarea>
20
+
</div>
21
+
22
+
<div class="flex flex-col gap-1">
23
+
<label class="m-0 p-0" for="location">location</label>
24
+
<div class="flex items-center gap-2 w-full">
25
+
{{ $location := "" }}
26
+
{{ if and .Profile .Profile.Location }}
27
+
{{ $location = .Profile.Location }}
28
+
{{ end }}
29
+
<span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span>
30
+
<input type="text" class="py-1 px-1 w-full" name="location" value="{{ $location }}">
31
+
</div>
32
+
</div>
33
+
34
+
<div class="flex flex-col gap-1">
35
+
<label class="m-0 p-0">social links</label>
36
+
<div class="flex items-center gap-2 py-1">
37
+
{{ $includeBsky := false }}
38
+
{{ if and .Profile .Profile.IncludeBluesky }}
39
+
{{ $includeBsky = true }}
40
+
{{ end }}
41
+
<input type="checkbox" id="includeBluesky" name="includeBluesky" value="on" {{if $includeBsky}}checked{{end}}>
42
+
<label for="includeBluesky" class="my-0 py-0 normal-case font-normal">Link to Bluesky account</label>
43
+
</div>
44
+
45
+
{{ $profile := .Profile }}
46
+
{{ range $idx, $s := (sequence 5) }}
47
+
{{ $link := "" }}
48
+
{{ if and $profile $profile.Links }}
49
+
{{ if lt $idx (len $profile.Links) }}
50
+
{{ $link = index $profile.Links $idx }}
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
<div class="flex items-center gap-2 w-full">
55
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
56
+
<input type="text" class="py-1 px-1 w-full" name="link{{$idx}}" value="{{ $link }}" placeholder="social link {{add $idx 1}}">
57
+
</div>
58
+
{{ end }}
59
+
</div>
60
+
61
+
<div class="flex flex-col gap-1">
62
+
<label class="m-0 p-0">vanity stats</label>
63
+
{{ range $idx, $s := (sequence 2) }}
64
+
{{ $stat := "" }}
65
+
{{ if and $profile $profile.Stats }}
66
+
{{ if lt $idx (len $profile.Stats) }}
67
+
{{ $s := index $profile.Stats $idx }}
68
+
{{ $stat = $s.Kind }}
69
+
{{ end }}
70
+
{{ end }}
71
+
72
+
{{ block "stat" (list $idx $stat) }} {{ end }}
73
+
{{ end }}
74
+
</div>
75
+
76
+
<div class="flex items-center gap-2 justify-between">
77
+
<button id="save-btn" type="submit" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm">
78
+
{{ i "check" "size-4" }} save
79
+
<span id="spinner" class="group">
80
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
81
+
</span>
82
+
</button>
83
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
84
+
<button id="cancel-btn" type="button" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm">
85
+
{{ i "x" "size-4" }} cancel
86
+
</button>
87
+
</a>
88
+
</div>
89
+
</form>
90
+
{{ end }}
91
+
92
+
{{ define "stat" }}
93
+
{{ $id := index . 0 }}
94
+
{{ $stat := index . 1 }}
95
+
<select class="stat-group w-full p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700 text-sm" id="stat{{$id}}" name="stat{{$id}}">
96
+
<option value="">choose stat</option>
97
+
{{ $stats := assoc
98
+
"merged-pull-request-count" "Merged PR Count"
99
+
"closed-pull-request-count" "Closed PR Count"
100
+
"open-pull-request-count" "Open PR Count"
101
+
"open-issue-count" "Open Issue Count"
102
+
"closed-issue-count" "Closed Issue Count"
103
+
"repository-count" "Repository Count"
104
+
}}
105
+
{{ range $s := $stats }}
106
+
{{ $value := index $s 0 }}
107
+
{{ $label := index $s 1 }}
108
+
<option value="{{ $value }}"{{ if eq $stat $value }} selected{{ end }}>{{ $label }}</option>
109
+
{{ end }}
110
+
</select>
111
+
{{ end }}
+42
appview/pages/templates/user/fragments/editPins.html
+42
appview/pages/templates/user/fragments/editPins.html
···
1
+
{{ define "user/fragments/editPins" }}
2
+
{{ $profile := .Profile }}
3
+
<form
4
+
hx-post="/profile/pins"
5
+
hx-disabled-elt="#save-btn,#cancel-btn"
6
+
hx-swap="none"
7
+
hx-indicator="#spinner">
8
+
<div class="flex items-center justify-between mb-2">
9
+
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
10
+
<div class="flex items-center gap-2">
11
+
<button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm">
12
+
{{ i "check" "w-3 h-3" }} save
13
+
<span id="spinner" class="group">
14
+
{{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }}
15
+
</span>
16
+
</button>
17
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
18
+
<button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
19
+
{{ i "x" "w-3 h-3" }} cancel
20
+
</button>
21
+
</a>
22
+
</div>
23
+
</div>
24
+
<div id="repos" class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
25
+
{{ range $idx, $r := .AllRepos }}
26
+
<div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
27
+
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
28
+
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
29
+
<div class="flex justify-between items-center w-full">
30
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span>
31
+
<div class="flex gap-1 items-center">
32
+
{{ i "star" "size-4 fill-current" }}
33
+
<span>{{ .RepoStats.StarCount }}</span>
34
+
</div>
35
+
</div>
36
+
</label>
37
+
</div>
38
+
{{ end }}
39
+
</div>
40
+
41
+
</form>
42
+
{{ end }}
+2
-1
appview/pages/templates/user/fragments/follow.html
+2
-1
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
2
<button id="followBtn"
3
-
class="btn mt-2 w-full"
3
+
class="btn mt-2 w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
6
hx-post="/follow?subject={{.UserDid}}"
···
13
13
hx-swap="outerHTML"
14
14
>
15
15
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
16
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
16
17
</button>
17
18
{{ end }}
+99
appview/pages/templates/user/fragments/profileCard.html
+99
appview/pages/templates/user/fragments/profileCard.html
···
1
+
{{ define "user/fragments/profileCard" }}
2
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
+
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
+
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
+
{{ if .AvatarUri }}
6
+
<div class="w-3/4 aspect-square relative">
7
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
8
+
</div>
9
+
{{ end }}
10
+
</div>
11
+
<div class="col-span-2">
12
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
13
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
14
+
{{ didOrHandle .UserDid .UserHandle }}
15
+
</p>
16
+
17
+
<div class="md:hidden">
18
+
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
19
+
</div>
20
+
</div>
21
+
<div class="col-span-3 md:col-span-full">
22
+
<div id="profile-bio" class="text-sm">
23
+
{{ $profile := .Profile }}
24
+
{{ with .Profile }}
25
+
26
+
{{ if .Description }}
27
+
<p class="text-base pb-4 md:pb-2">{{ .Description }}</p>
28
+
{{ end }}
29
+
30
+
<div class="hidden md:block">
31
+
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
32
+
</div>
33
+
34
+
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
35
+
{{ if .Location }}
36
+
<div class="flex items-center gap-2">
37
+
<span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span>
38
+
<span>{{ .Location }}</span>
39
+
</div>
40
+
{{ end }}
41
+
{{ if .IncludeBluesky }}
42
+
<div class="flex items-center gap-2">
43
+
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
44
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
45
+
</div>
46
+
{{ end }}
47
+
{{ range $link := .Links }}
48
+
{{ if $link }}
49
+
<div class="flex items-center gap-2">
50
+
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
51
+
<a href="{{ $link }}">{{ $link }}</a>
52
+
</div>
53
+
{{ end }}
54
+
{{ end }}
55
+
{{ if not $profile.IsStatsEmpty }}
56
+
<div class="flex items-center justify-evenly gap-2 py-2">
57
+
{{ range $stat := .Stats }}
58
+
{{ if $stat.Kind }}
59
+
<div class="flex flex-col items-center gap-2">
60
+
<span class="text-xl font-bold">{{ $stat.Value }}</span>
61
+
<span>{{ $stat.Kind.String }}</span>
62
+
</div>
63
+
{{ end }}
64
+
{{ end }}
65
+
</div>
66
+
{{ end }}
67
+
</div>
68
+
{{ end }}
69
+
{{ if ne .FollowStatus.String "IsSelf" }}
70
+
{{ template "user/fragments/follow" . }}
71
+
{{ else }}
72
+
<button id="editBtn"
73
+
class="btn mt-2 w-full flex items-center gap-2 group"
74
+
hx-target="#profile-bio"
75
+
hx-get="/profile/edit-bio"
76
+
hx-swap="innerHTML">
77
+
{{ i "pencil" "w-4 h-4" }}
78
+
edit
79
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
80
+
</button>
81
+
{{ end }}
82
+
</div>
83
+
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
84
+
</div>
85
+
</div>
86
+
</div>
87
+
{{ end }}
88
+
89
+
{{ define "followerFollowing" }}
90
+
{{ $followers := index . 0 }}
91
+
{{ $following := index . 1 }}
92
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
93
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
94
+
<span id="followers">{{ $followers }} followers</span>
95
+
<span class="select-none after:content-['ยท']"></span>
96
+
<span id="following">{{ $following }} following</span>
97
+
</div>
98
+
{{ end }}
99
+
+35
-34
appview/pages/templates/user/login.html
+35
-34
appview/pages/templates/user/login.html
···
7
7
name="viewport"
8
8
content="width=device-width, initial-scale=1.0"
9
9
/>
10
+
<meta
11
+
property="og:title"
12
+
content="login ยท tangled"
13
+
/>
14
+
<meta
15
+
property="og:url"
16
+
content="https://tangled.sh/login"
17
+
/>
18
+
<meta
19
+
property="og:description"
20
+
content="login to tangled"
21
+
/>
10
22
<script src="/static/htmx.min.js"></script>
11
-
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
-
<title>login</title>
23
+
<link
24
+
rel="stylesheet"
25
+
href="/static/tw.css?{{ cssContentHash }}"
26
+
type="text/css"
27
+
/>
28
+
<title>login · tangled</title>
13
29
</head>
14
30
<body class="flex items-center justify-center min-h-screen">
15
-
<main class="max-w-7xl px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white">
31
+
<main class="max-w-md px-6 -mt-4">
32
+
<h1
33
+
class="text-center text-2xl font-semibold italic dark:text-white"
34
+
>
17
35
tangled
18
36
</h1>
19
37
<h2 class="text-center text-xl italic dark:text-white">
20
38
tightly-knit social coding.
21
39
</h2>
22
40
<form
23
-
class="w-full mt-4"
41
+
class="mt-4 max-w-sm mx-auto"
24
42
hx-post="/login"
25
43
hx-swap="none"
26
-
hx-disabled-elt="this"
44
+
hx-disabled-elt="#login-button"
27
45
>
28
46
<div class="flex flex-col">
29
47
<label for="handle">handle</label>
30
-
<input
31
-
type="text"
32
-
id="handle"
33
-
name="handle"
34
-
tabindex="1"
35
-
required
36
-
/>
37
-
<span class="text-xs text-gray-500 mt-1">
38
-
You need to use your
39
-
<a href="https://bsky.app">Bluesky</a> handle to log
40
-
in.
41
-
</span>
42
-
</div>
43
-
44
-
<div class="flex flex-col mt-2">
45
-
<label for="app_password">app password</label>
46
48
<input
47
-
type="password"
48
-
id="app_password"
49
-
name="app_password"
50
-
tabindex="2"
49
+
type="text"
50
+
id="handle"
51
+
name="handle"
52
+
tabindex="1"
51
53
required
52
54
/>
53
-
<span class="text-xs text-gray-500 mt-1">
54
-
Generate an app password
55
-
<a
56
-
href="https://bsky.app/settings/app-passwords"
57
-
target="_blank"
58
-
>here</a
59
-
>.
55
+
<span class="text-sm text-gray-500 mt-1">
56
+
Use your
57
+
<a href="https://bsky.app">Bluesky</a> handle to log
58
+
in. You will then be redirected to your PDS to
59
+
complete authentication.
60
60
</span>
61
61
</div>
62
62
···
70
70
</button>
71
71
</form>
72
72
<p class="text-sm text-gray-500">
73
-
Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel:
73
+
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
+
IRC channel:
74
75
<a href="https://web.libera.chat/#tangled"
75
76
><code>#tangled</code> on Libera Chat</a
76
77
>.
+87
-91
appview/pages/templates/user/profile.html
+87
-91
appview/pages/templates/user/profile.html
···
1
-
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
2
9
3
10
{{ define "content" }}
4
-
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
5
-
<div class="md:col-span-1 order-1 md:order-1">
6
-
{{ block "profileCard" . }}{{ end }}
11
+
<div class="grid grid-cols-1 md:grid-cols-8 gap-6">
12
+
<div class="md:col-span-2 order-1 md:order-1">
13
+
{{ template "user/fragments/profileCard" .Card }}
7
14
</div>
8
-
<div class="md:col-span-2 order-2 md:order-2">
15
+
<div id="all-repos" class="md:col-span-3 order-2 md:order-2">
9
16
{{ block "ownRepos" . }}{{ end }}
10
17
{{ block "collaboratingRepos" . }}{{ end }}
11
18
</div>
12
-
<div class="md:col-span-2 order-3 md:order-3">
19
+
<div class="md:col-span-3 order-3 md:order-3">
13
20
{{ block "profileTimeline" . }}{{ end }}
14
21
</div>
15
22
</div>
16
23
{{ end }}
17
24
18
25
{{ define "profileTimeline" }}
19
-
<p class="text-sm font-bold py-2 dark:text-white px-6">ACTIVITY</p>
26
+
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
20
27
<div class="flex flex-col gap-6 relative">
21
28
{{ with .ProfileTimeline }}
22
29
{{ range $idx, $byMonth := .ByMonth }}
···
225
232
{{ end }}
226
233
{{ end }}
227
234
228
-
{{ define "profileCard" }}
229
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
230
-
<div class="grid grid-cols-3 md:grid-cols-1 gap-3 items-center">
231
-
<div id="avatar" class="col-span-1 md-col-span-full flex justify-center items-center">
232
-
{{ if .AvatarUri }}
233
-
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
234
-
{{ end }}
235
+
{{ define "ownRepos" }}
236
+
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
237
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
238
+
class="flex text-black dark:text-white items-center gap-4 no-underline hover:no-underline group">
239
+
<span>PINNED REPOS</span>
240
+
<span class="flex md:hidden group-hover:flex gap-2 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
241
+
view all {{ i "chevron-right" "w-4 h-4" }}
242
+
</span>
243
+
</a>
244
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
245
+
<button
246
+
hx-get="profile/edit-pins"
247
+
hx-target="#all-repos"
248
+
class="btn font-normal text-sm flex gap-2 items-center group">
249
+
{{ i "pencil" "w-3 h-3" }}
250
+
edit
251
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
252
+
</button>
253
+
{{ end }}
254
+
</div>
255
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
256
+
{{ range .Repos }}
257
+
<div
258
+
id="repo-card"
259
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
260
+
<div id="repo-card-name" class="font-medium">
261
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}"
262
+
>{{ .Name }}</a
263
+
>
264
+
</div>
265
+
{{ if .Description }}
266
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
267
+
{{ .Description }}
268
+
</div>
269
+
{{ end }}
270
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
271
+
{{ if .RepoStats.StarCount }}
272
+
<div class="flex gap-1 items-center text-sm">
273
+
{{ i "star" "w-3 h-3 fill-current" }}
274
+
<span>{{ .RepoStats.StarCount }}</span>
275
+
</div>
276
+
{{ end }}
277
+
</div>
235
278
</div>
236
-
<div id="text" class="col-span-2 md:col-span-full">
237
-
<p
238
-
title="{{ didOrHandle .UserDid .UserHandle }}"
239
-
class="text-lg font-bold md:text-center dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
240
-
{{ didOrHandle .UserDid .UserHandle }}
241
-
</p>
242
-
<div class="text-sm md:text-center dark:text-gray-300">
243
-
<span id="followers">{{ .ProfileStats.Followers }} followers</span>
244
-
<span class="px-1 select-none after:content-['ยท']"></span>
245
-
<span id="following">{{ .ProfileStats.Following }} following</span>
246
-
</div>
247
-
248
-
{{ if ne .FollowStatus.String "IsSelf" }}
249
-
{{ template "user/fragments/follow" . }}
250
-
{{ end }}
251
-
</div>
252
-
</div>
279
+
{{ else }}
280
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
281
+
{{ end }}
253
282
</div>
254
283
{{ end }}
255
284
256
-
{{ define "ownRepos" }}
257
-
<p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p>
258
-
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
259
-
{{ range .Repos }}
260
-
<div
261
-
id="repo-card"
262
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"
263
-
>
264
-
<div id="repo-card-name" class="font-medium dark:text-white">
265
-
<a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}"
266
-
>{{ .Name }}</a
267
-
>
268
-
</div>
269
-
{{ if .Description }}
270
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
271
-
{{ .Description }}
272
-
</div>
273
-
{{ end }}
274
-
<div
275
-
class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"
276
-
>
277
-
278
-
{{ if .RepoStats.StarCount }}
279
-
<div class="flex gap-1 items-center text-sm">
280
-
{{ i "star" "w-3 h-3 fill-current" }}
281
-
<span>{{ .RepoStats.StarCount }}</span>
282
-
</div>
283
-
{{ end }}
284
-
</div>
285
+
{{ define "collaboratingRepos" }}
286
+
{{ if gt (len .CollaboratingRepos) 0 }}
287
+
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
288
+
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
289
+
{{ range .CollaboratingRepos }}
290
+
<div
291
+
id="repo-card"
292
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col">
293
+
<div id="repo-card-name" class="font-medium dark:text-white">
294
+
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
295
+
{{ index $.DidHandleMap .Did }}/{{ .Name }}
296
+
</a>
285
297
</div>
286
-
{{ else }}
287
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
288
-
{{ end }}
289
-
</div>
290
-
291
-
<p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p>
292
-
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
293
-
{{ range .CollaboratingRepos }}
294
-
<div
295
-
id="repo-card"
296
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"
297
-
>
298
-
<div id="repo-card-name" class="font-medium dark:text-white">
299
-
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
300
-
{{ index $.DidHandleMap .Did }}/{{ .Name }}
301
-
</a>
298
+
{{ if .Description }}
299
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
300
+
{{ .Description }}
302
301
</div>
303
-
{{ if .Description }}
304
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
305
-
{{ .Description }}
302
+
{{ end }}
303
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
304
+
305
+
{{ if .RepoStats.StarCount }}
306
+
<div class="flex gap-1 items-center text-sm">
307
+
{{ i "star" "w-3 h-3 fill-current" }}
308
+
<span>{{ .RepoStats.StarCount }}</span>
306
309
</div>
307
310
{{ end }}
308
-
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
309
-
310
-
{{ if .RepoStats.StarCount }}
311
-
<div class="flex gap-1 items-center text-sm">
312
-
{{ i "star" "w-3 h-3 fill-current" }}
313
-
<span>{{ .RepoStats.StarCount }}</span>
314
-
</div>
315
-
{{ end }}
316
-
</div>
317
311
</div>
318
-
{{ else }}
319
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
320
-
{{ end }}
312
+
</div>
313
+
{{ else }}
314
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
315
+
{{ end }}
321
316
</div>
317
+
{{ end }}
322
318
{{ end }}
+51
appview/pages/templates/user/repos.html
+51
appview/pages/templates/user/repos.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
<div class="grid grid-cols-1 md:grid-cols-8 gap-6">
12
+
<div class="md:col-span-2 order-1 md:order-1">
13
+
{{ template "user/fragments/profileCard" .Card }}
14
+
</div>
15
+
<div id="all-repos" class="md:col-span-6 order-2 md:order-2">
16
+
{{ block "ownRepos" . }}{{ end }}
17
+
</div>
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "ownRepos" }}
22
+
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
+
{{ range .Repos }}
25
+
<div
26
+
id="repo-card"
27
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
28
+
<div id="repo-card-name" class="font-medium">
29
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}"
30
+
>{{ .Name }}</a
31
+
>
32
+
</div>
33
+
{{ if .Description }}
34
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
35
+
{{ .Description }}
36
+
</div>
37
+
{{ end }}
38
+
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
39
+
{{ if .RepoStats.StarCount }}
40
+
<div class="flex gap-1 items-center text-sm">
41
+
{{ i "star" "w-3 h-3 fill-current" }}
42
+
<span>{{ .RepoStats.StarCount }}</span>
43
+
</div>
44
+
{{ end }}
45
+
</div>
46
+
</div>
47
+
{{ else }}
48
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
49
+
{{ end }}
50
+
</div>
51
+
{{ end }}
+27
-18
appview/settings/settings.go
+27
-18
appview/settings/settings.go
···
13
13
"github.com/go-chi/chi/v5"
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
15
"tangled.sh/tangled.sh/core/appview"
16
-
"tangled.sh/tangled.sh/core/appview/auth"
17
16
"tangled.sh/tangled.sh/core/appview/db"
18
17
"tangled.sh/tangled.sh/core/appview/email"
19
18
"tangled.sh/tangled.sh/core/appview/middleware"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
20
20
"tangled.sh/tangled.sh/core/appview/pages"
21
21
22
22
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
27
27
28
28
type Settings struct {
29
29
Db *db.DB
30
-
Auth *auth.Auth
30
+
OAuth *oauth.OAuth
31
31
Pages *pages.Pages
32
32
Config *appview.Config
33
33
}
···
35
35
func (s *Settings) Router() http.Handler {
36
36
r := chi.NewRouter()
37
37
38
-
r.Use(middleware.AuthMiddleware(s.Auth))
38
+
r.Use(middleware.AuthMiddleware(s.OAuth))
39
39
40
40
r.Get("/", s.settings)
41
41
···
56
56
}
57
57
58
58
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
59
-
user := s.Auth.GetUser(r)
59
+
user := s.OAuth.GetUser(r)
60
60
pubKeys, err := db.GetPublicKeys(s.Db, user.Did)
61
61
if err != nil {
62
62
log.Println(err)
···
79
79
verifyURL := s.verifyUrl(did, emailAddr, code)
80
80
81
81
return email.Email{
82
-
APIKey: s.Config.ResendApiKey,
82
+
APIKey: s.Config.Resend.ApiKey,
83
83
From: "noreply@notifs.tangled.sh",
84
84
To: emailAddr,
85
85
Subject: "Verify your Tangled email",
···
111
111
log.Println("unimplemented")
112
112
return
113
113
case http.MethodPut:
114
-
did := s.Auth.GetDid(r)
114
+
did := s.OAuth.GetDid(r)
115
115
emAddr := r.FormValue("email")
116
116
emAddr = strings.TrimSpace(emAddr)
117
117
···
174
174
s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
175
175
return
176
176
case http.MethodDelete:
177
-
did := s.Auth.GetDid(r)
177
+
did := s.OAuth.GetDid(r)
178
178
emailAddr := r.FormValue("email")
179
179
emailAddr = strings.TrimSpace(emailAddr)
180
180
···
207
207
208
208
func (s *Settings) verifyUrl(did string, email string, code string) string {
209
209
var appUrl string
210
-
if s.Config.Dev {
211
-
appUrl = "http://" + s.Config.ListenAddr
210
+
if s.Config.Core.Dev {
211
+
appUrl = "http://" + s.Config.Core.ListenAddr
212
212
} else {
213
213
appUrl = "https://tangled.sh"
214
214
}
···
252
252
return
253
253
}
254
254
255
-
did := s.Auth.GetDid(r)
255
+
did := s.OAuth.GetDid(r)
256
256
emAddr := r.FormValue("email")
257
257
emAddr = strings.TrimSpace(emAddr)
258
258
···
323
323
}
324
324
325
325
func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
326
-
did := s.Auth.GetDid(r)
326
+
did := s.OAuth.GetDid(r)
327
327
emailAddr := r.FormValue("email")
328
328
emailAddr = strings.TrimSpace(emailAddr)
329
329
···
348
348
log.Println("unimplemented")
349
349
return
350
350
case http.MethodPut:
351
-
did := s.Auth.GetDid(r)
351
+
did := s.OAuth.GetDid(r)
352
352
key := r.FormValue("key")
353
353
key = strings.TrimSpace(key)
354
354
name := r.FormValue("name")
355
-
client, _ := s.Auth.AuthorizedClient(r)
355
+
client, err := s.OAuth.AuthorizedClient(r)
356
+
if err != nil {
357
+
s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.")
358
+
return
359
+
}
356
360
357
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
361
+
_, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key))
358
362
if err != nil {
359
363
log.Printf("parsing public key: %s", err)
360
364
s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
···
378
382
}
379
383
380
384
// store in pds too
381
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
385
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
382
386
Collection: tangled.PublicKeyNSID,
383
387
Repo: did,
384
388
Rkey: rkey,
···
409
413
return
410
414
411
415
case http.MethodDelete:
412
-
did := s.Auth.GetDid(r)
416
+
did := s.OAuth.GetDid(r)
413
417
q := r.URL.Query()
414
418
415
419
name := q.Get("name")
···
420
424
log.Println(rkey)
421
425
log.Println(key)
422
426
423
-
client, _ := s.Auth.AuthorizedClient(r)
427
+
client, err := s.OAuth.AuthorizedClient(r)
428
+
if err != nil {
429
+
log.Printf("failed to authorize client: %s", err)
430
+
s.Pages.Notice(w, "settings-keys", "Failed to authorize client.")
431
+
return
432
+
}
424
433
425
434
if err := db.DeletePublicKey(s.Db, did, name, key); err != nil {
426
435
log.Printf("removing public key: %s", err)
···
430
439
431
440
if rkey != "" {
432
441
// remove from pds too
433
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
442
+
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
434
443
Collection: tangled.PublicKeyNSID,
435
444
Repo: did,
436
445
Rkey: rkey,
+29
-19
appview/state/artifact.go
+29
-19
appview/state/artifact.go
···
17
17
"tangled.sh/tangled.sh/core/appview"
18
18
"tangled.sh/tangled.sh/core/appview/db"
19
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/knotclient"
20
21
"tangled.sh/tangled.sh/core/types"
21
22
)
22
23
23
24
// TODO: proper statuses here on early exit
24
25
func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {
25
-
user := s.auth.GetUser(r)
26
+
user := s.oauth.GetUser(r)
26
27
tagParam := chi.URLParam(r, "tag")
27
28
f, err := s.fullyResolvedRepo(r)
28
29
if err != nil {
···
46
47
}
47
48
defer file.Close()
48
49
49
-
client, _ := s.auth.AuthorizedClient(r)
50
+
client, err := s.oauth.AuthorizedClient(r)
51
+
if err != nil {
52
+
log.Println("failed to get authorized client", err)
53
+
s.pages.Notice(w, "upload", "failed to get authorized client")
54
+
return
55
+
}
50
56
51
-
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
57
+
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
52
58
if err != nil {
53
59
log.Println("failed to upload blob", err)
54
60
s.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
60
66
rkey := appview.TID()
61
67
createdAt := time.Now()
62
68
63
-
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
69
+
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
64
70
Collection: tangled.RepoArtifactNSID,
65
71
Repo: user.Did,
66
72
Rkey: rkey,
···
140
146
return
141
147
}
142
148
143
-
client, _ := s.auth.AuthorizedClient(r)
149
+
client, err := s.oauth.AuthorizedClient(r)
150
+
if err != nil {
151
+
log.Println("failed to get authorized client", err)
152
+
return
153
+
}
144
154
145
155
artifacts, err := db.GetArtifact(
146
156
s.db,
147
-
db.Filter("repo_at", f.RepoAt),
148
-
db.Filter("tag", tag.Tag.Hash[:]),
149
-
db.Filter("name", filename),
157
+
db.FilterEq("repo_at", f.RepoAt),
158
+
db.FilterEq("tag", tag.Tag.Hash[:]),
159
+
db.FilterEq("name", filename),
150
160
)
151
161
if err != nil {
152
162
log.Println("failed to get artifacts", err)
···
159
169
160
170
artifact := artifacts[0]
161
171
162
-
getBlobResp, err := comatproto.SyncGetBlob(r.Context(), client, artifact.BlobCid.String(), artifact.Did)
172
+
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
163
173
if err != nil {
164
174
log.Println("failed to get blob from pds", err)
165
175
return
···
171
181
172
182
// TODO: proper statuses here on early exit
173
183
func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) {
174
-
user := s.auth.GetUser(r)
184
+
user := s.oauth.GetUser(r)
175
185
tagParam := chi.URLParam(r, "tag")
176
186
filename := chi.URLParam(r, "file")
177
187
f, err := s.fullyResolvedRepo(r)
···
180
190
return
181
191
}
182
192
183
-
client, _ := s.auth.AuthorizedClient(r)
193
+
client, _ := s.oauth.AuthorizedClient(r)
184
194
185
195
tag := plumbing.NewHash(tagParam)
186
196
187
197
artifacts, err := db.GetArtifact(
188
198
s.db,
189
-
db.Filter("repo_at", f.RepoAt),
190
-
db.Filter("tag", tag[:]),
191
-
db.Filter("name", filename),
199
+
db.FilterEq("repo_at", f.RepoAt),
200
+
db.FilterEq("tag", tag[:]),
201
+
db.FilterEq("name", filename),
192
202
)
193
203
if err != nil {
194
204
log.Println("failed to get artifacts", err)
···
208
218
return
209
219
}
210
220
211
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
221
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
212
222
Collection: tangled.RepoArtifactNSID,
213
223
Repo: user.Did,
214
224
Rkey: artifact.Rkey,
···
228
238
defer tx.Rollback()
229
239
230
240
err = db.DeleteArtifact(tx,
231
-
db.Filter("repo_at", f.RepoAt),
232
-
db.Filter("tag", artifact.Tag[:]),
233
-
db.Filter("name", filename),
241
+
db.FilterEq("repo_at", f.RepoAt),
242
+
db.FilterEq("tag", artifact.Tag[:]),
243
+
db.FilterEq("name", filename),
234
244
)
235
245
if err != nil {
236
246
log.Println("failed to remove artifact record from db", err)
···
254
264
return nil, err
255
265
}
256
266
257
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
267
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
258
268
if err != nil {
259
269
return nil, err
260
270
}
+31
-4
appview/state/follow.go
+31
-4
appview/state/follow.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
+
"github.com/posthog/posthog-go"
10
11
"tangled.sh/tangled.sh/core/api/tangled"
11
12
"tangled.sh/tangled.sh/core/appview"
12
13
"tangled.sh/tangled.sh/core/appview/db"
···
14
15
)
15
16
16
17
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
17
-
currentUser := s.auth.GetUser(r)
18
+
currentUser := s.oauth.GetUser(r)
18
19
19
20
subject := r.URL.Query().Get("subject")
20
21
if subject == "" {
···
32
33
return
33
34
}
34
35
35
-
client, _ := s.auth.AuthorizedClient(r)
36
+
client, err := s.oauth.AuthorizedClient(r)
37
+
if err != nil {
38
+
log.Println("failed to authorize client")
39
+
return
40
+
}
36
41
37
42
switch r.Method {
38
43
case http.MethodPost:
39
44
createdAt := time.Now().Format(time.RFC3339)
40
45
rkey := appview.TID()
41
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
46
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
42
47
Collection: tangled.GraphFollowNSID,
43
48
Repo: currentUser.Did,
44
49
Rkey: rkey,
···
66
71
FollowStatus: db.IsFollowing,
67
72
})
68
73
74
+
if !s.config.Core.Dev {
75
+
err = s.posthog.Enqueue(posthog.Capture{
76
+
DistinctId: currentUser.Did,
77
+
Event: "follow",
78
+
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
79
+
})
80
+
if err != nil {
81
+
log.Println("failed to enqueue posthog event:", err)
82
+
}
83
+
}
84
+
69
85
return
70
86
case http.MethodDelete:
71
87
// find the record in the db
···
75
91
return
76
92
}
77
93
78
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
94
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
79
95
Collection: tangled.GraphFollowNSID,
80
96
Repo: currentUser.Did,
81
97
Rkey: follow.Rkey,
···
96
112
UserDid: subjectIdent.DID.String(),
97
113
FollowStatus: db.IsNotFollowing,
98
114
})
115
+
116
+
if !s.config.Core.Dev {
117
+
err = s.posthog.Enqueue(posthog.Capture{
118
+
DistinctId: currentUser.Did,
119
+
Event: "unfollow",
120
+
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
121
+
})
122
+
if err != nil {
123
+
log.Println("failed to enqueue posthog event:", err)
124
+
}
125
+
}
99
126
100
127
return
101
128
}
+28
-20
appview/state/git_http.go
+28
-20
appview/state/git_http.go
···
15
15
repo := chi.URLParam(r, "repo")
16
16
17
17
scheme := "https"
18
-
if s.config.Dev {
18
+
if s.config.Core.Dev {
19
19
scheme = "http"
20
20
}
21
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
22
-
resp, err := http.Get(targetURL)
23
-
if err != nil {
24
-
http.Error(w, err.Error(), http.StatusInternalServerError)
25
-
return
26
-
}
27
-
defer resp.Body.Close()
28
21
29
-
// Copy response headers
30
-
for k, v := range resp.Header {
31
-
w.Header()[k] = v
32
-
}
22
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
23
+
s.proxyRequest(w, r, targetURL)
33
24
34
-
// Set response status code
35
-
w.WriteHeader(resp.StatusCode)
25
+
}
36
26
37
-
// Copy response body
38
-
if _, err := io.Copy(w, resp.Body); err != nil {
39
-
http.Error(w, err.Error(), http.StatusInternalServerError)
27
+
func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
28
+
user, ok := r.Context().Value("resolvedId").(identity.Identity)
29
+
if !ok {
30
+
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
40
31
return
41
32
}
33
+
knot := r.Context().Value("knot").(string)
34
+
repo := chi.URLParam(r, "repo")
42
35
36
+
scheme := "https"
37
+
if s.config.Core.Dev {
38
+
scheme = "http"
39
+
}
40
+
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
42
+
s.proxyRequest(w, r, targetURL)
43
43
}
44
44
45
-
func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
45
+
func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) {
46
46
user, ok := r.Context().Value("resolvedId").(identity.Identity)
47
47
if !ok {
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
···
52
52
repo := chi.URLParam(r, "repo")
53
53
54
54
scheme := "https"
55
-
if s.config.Dev {
55
+
if s.config.Core.Dev {
56
56
scheme = "http"
57
57
}
58
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
58
+
59
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
60
+
s.proxyRequest(w, r, targetURL)
61
+
}
62
+
63
+
func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) {
59
64
client := &http.Client{}
60
65
61
66
// Create new request
···
67
72
68
73
// Copy original headers
69
74
proxyReq.Header = r.Header
75
+
76
+
repoOwnerHandle := chi.URLParam(r, "user")
77
+
proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle)
70
78
71
79
// Execute request
72
80
resp, err := client.Do(proxyReq)
+52
-3
appview/state/middleware.go
+52
-3
appview/state/middleware.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
"log"
6
7
"net/http"
7
8
"strconv"
···
20
21
return func(next http.Handler) http.Handler {
21
22
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
23
// requires auth also
23
-
actor := s.auth.GetUser(r)
24
+
actor := s.oauth.GetUser(r)
24
25
if actor == nil {
25
26
// we need a logged in user
26
27
log.Printf("not logged in, redirecting")
···
54
55
return func(next http.Handler) http.Handler {
55
56
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56
57
// requires auth also
57
-
actor := s.auth.GetUser(r)
58
+
actor := s.oauth.GetUser(r)
58
59
if actor == nil {
59
60
// we need a logged in user
60
61
log.Printf("not logged in, redirecting")
···
131
132
if err != nil {
132
133
// invalid did or handle
133
134
log.Println("failed to resolve repo")
134
-
w.WriteHeader(http.StatusNotFound)
135
+
s.pages.Error404(w)
135
136
return
136
137
}
137
138
···
171
172
172
173
ctx := context.WithValue(r.Context(), "pull", pr)
173
174
175
+
if pr.IsStacked() {
176
+
stack, err := db.GetStack(s.db, pr.StackId)
177
+
if err != nil {
178
+
log.Println("failed to get stack", err)
179
+
return
180
+
}
181
+
abandonedPulls, err := db.GetAbandonedPulls(s.db, pr.StackId)
182
+
if err != nil {
183
+
log.Println("failed to get abandoned pulls", err)
184
+
return
185
+
}
186
+
187
+
ctx = context.WithValue(ctx, "stack", stack)
188
+
ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls)
189
+
}
190
+
174
191
next.ServeHTTP(w, r.WithContext(ctx))
175
192
})
176
193
}
177
194
}
195
+
196
+
// this should serve the go-import meta tag even if the path is technically
197
+
// a 404 like tangled.sh/oppi.li/go-git/v5
198
+
func GoImport(s *State) middleware.Middleware {
199
+
return func(next http.Handler) http.Handler {
200
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201
+
f, err := s.fullyResolvedRepo(r)
202
+
if err != nil {
203
+
log.Println("failed to fully resolve repo", err)
204
+
http.Error(w, "invalid repo url", http.StatusNotFound)
205
+
return
206
+
}
207
+
208
+
fullName := f.OwnerHandle() + "/" + f.RepoName
209
+
210
+
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
211
+
if r.URL.Query().Get("go-get") == "1" {
212
+
html := fmt.Sprintf(
213
+
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
214
+
fullName,
215
+
fullName,
216
+
)
217
+
w.Header().Set("Content-Type", "text/html")
218
+
w.Write([]byte(html))
219
+
return
220
+
}
221
+
}
222
+
223
+
next.ServeHTTP(w, r)
224
+
})
225
+
}
226
+
}
+339
-14
appview/state/profile.go
+339
-14
appview/state/profile.go
···
7
7
"fmt"
8
8
"log"
9
9
"net/http"
10
+
"slices"
11
+
"strings"
10
12
13
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
14
"github.com/bluesky-social/indigo/atproto/identity"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
16
+
lexutil "github.com/bluesky-social/indigo/lex/util"
12
17
"github.com/go-chi/chi/v5"
18
+
"github.com/posthog/posthog-go"
19
+
"tangled.sh/tangled.sh/core/api/tangled"
13
20
"tangled.sh/tangled.sh/core/appview/db"
14
21
"tangled.sh/tangled.sh/core/appview/pages"
15
22
)
16
23
17
-
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
24
+
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
25
+
tabVal := r.URL.Query().Get("tab")
26
+
switch tabVal {
27
+
case "":
28
+
s.profilePage(w, r)
29
+
case "repos":
30
+
s.reposPage(w, r)
31
+
}
32
+
}
33
+
34
+
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
18
35
didOrHandle := chi.URLParam(r, "user")
19
36
if didOrHandle == "" {
20
37
http.Error(w, "Bad request", http.StatusBadRequest)
···
27
44
return
28
45
}
29
46
47
+
profile, err := db.GetProfile(s.db, ident.DID.String())
48
+
if err != nil {
49
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
50
+
}
51
+
30
52
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
31
53
if err != nil {
32
54
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
33
55
}
34
56
57
+
// filter out ones that are pinned
58
+
pinnedRepos := []db.Repo{}
59
+
for i, r := range repos {
60
+
// if this is a pinned repo, add it
61
+
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
62
+
pinnedRepos = append(pinnedRepos, r)
63
+
}
64
+
65
+
// if there are no saved pins, add the first 4 repos
66
+
if profile.IsPinnedReposEmpty() && i < 4 {
67
+
pinnedRepos = append(pinnedRepos, r)
68
+
}
69
+
}
70
+
35
71
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
36
72
if err != nil {
37
73
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
38
74
}
39
75
76
+
pinnedCollaboratingRepos := []db.Repo{}
77
+
for _, r := range collaboratingRepos {
78
+
// if this is a pinned repo, add it
79
+
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
80
+
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
81
+
}
82
+
}
83
+
40
84
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
41
85
if err != nil {
42
86
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
···
76
120
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
77
121
}
78
122
79
-
loggedInUser := s.auth.GetUser(r)
123
+
loggedInUser := s.oauth.GetUser(r)
80
124
followStatus := db.IsNotFollowing
81
125
if loggedInUser != nil {
82
126
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
···
85
129
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
86
130
s.pages.ProfilePage(w, pages.ProfilePageParams{
87
131
LoggedInUser: loggedInUser,
88
-
UserDid: ident.DID.String(),
89
-
UserHandle: ident.Handle.String(),
90
-
Repos: repos,
91
-
CollaboratingRepos: collaboratingRepos,
92
-
ProfileStats: pages.ProfileStats{
93
-
Followers: followers,
94
-
Following: following,
132
+
Repos: pinnedRepos,
133
+
CollaboratingRepos: pinnedCollaboratingRepos,
134
+
DidHandleMap: didHandleMap,
135
+
Card: pages.ProfileCard{
136
+
UserDid: ident.DID.String(),
137
+
UserHandle: ident.Handle.String(),
138
+
AvatarUri: profileAvatarUri,
139
+
Profile: profile,
140
+
FollowStatus: followStatus,
141
+
Followers: followers,
142
+
Following: following,
95
143
},
96
-
FollowStatus: db.FollowStatus(followStatus),
97
-
DidHandleMap: didHandleMap,
98
-
AvatarUri: profileAvatarUri,
99
144
ProfileTimeline: timeline,
100
145
})
101
146
}
102
147
148
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
149
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
150
+
if !ok {
151
+
s.pages.Error404(w)
152
+
return
153
+
}
154
+
155
+
profile, err := db.GetProfile(s.db, ident.DID.String())
156
+
if err != nil {
157
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
158
+
}
159
+
160
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
161
+
if err != nil {
162
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
163
+
}
164
+
165
+
loggedInUser := s.oauth.GetUser(r)
166
+
followStatus := db.IsNotFollowing
167
+
if loggedInUser != nil {
168
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
169
+
}
170
+
171
+
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
172
+
if err != nil {
173
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
174
+
}
175
+
176
+
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
177
+
178
+
s.pages.ReposPage(w, pages.ReposPageParams{
179
+
LoggedInUser: loggedInUser,
180
+
Repos: repos,
181
+
Card: pages.ProfileCard{
182
+
UserDid: ident.DID.String(),
183
+
UserHandle: ident.Handle.String(),
184
+
AvatarUri: profileAvatarUri,
185
+
Profile: profile,
186
+
FollowStatus: followStatus,
187
+
Followers: followers,
188
+
Following: following,
189
+
},
190
+
})
191
+
}
192
+
103
193
func (s *State) GetAvatarUri(handle string) string {
104
-
secret := s.config.AvatarSharedSecret
194
+
secret := s.config.Avatar.SharedSecret
105
195
h := hmac.New(sha256.New, []byte(secret))
106
196
h.Write([]byte(handle))
107
197
signature := hex.EncodeToString(h.Sum(nil))
108
-
return fmt.Sprintf("%s/%s/%s", s.config.AvatarHost, signature, handle)
198
+
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
199
+
}
200
+
201
+
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
202
+
user := s.oauth.GetUser(r)
203
+
204
+
err := r.ParseForm()
205
+
if err != nil {
206
+
log.Println("invalid profile update form", err)
207
+
s.pages.Notice(w, "update-profile", "Invalid form.")
208
+
return
209
+
}
210
+
211
+
profile, err := db.GetProfile(s.db, user.Did)
212
+
if err != nil {
213
+
log.Printf("getting profile data for %s: %s", user.Did, err)
214
+
}
215
+
216
+
profile.Description = r.FormValue("description")
217
+
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
218
+
profile.Location = r.FormValue("location")
219
+
220
+
var links [5]string
221
+
for i := range 5 {
222
+
iLink := r.FormValue(fmt.Sprintf("link%d", i))
223
+
links[i] = iLink
224
+
}
225
+
profile.Links = links
226
+
227
+
// Parse stats (exactly 2)
228
+
stat0 := r.FormValue("stat0")
229
+
stat1 := r.FormValue("stat1")
230
+
231
+
if stat0 != "" {
232
+
profile.Stats[0].Kind = db.VanityStatKind(stat0)
233
+
}
234
+
235
+
if stat1 != "" {
236
+
profile.Stats[1].Kind = db.VanityStatKind(stat1)
237
+
}
238
+
239
+
if err := db.ValidateProfile(s.db, profile); err != nil {
240
+
log.Println("invalid profile", err)
241
+
s.pages.Notice(w, "update-profile", err.Error())
242
+
return
243
+
}
244
+
245
+
s.updateProfile(profile, w, r)
246
+
return
247
+
}
248
+
249
+
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
250
+
user := s.oauth.GetUser(r)
251
+
252
+
err := r.ParseForm()
253
+
if err != nil {
254
+
log.Println("invalid profile update form", err)
255
+
s.pages.Notice(w, "update-profile", "Invalid form.")
256
+
return
257
+
}
258
+
259
+
profile, err := db.GetProfile(s.db, user.Did)
260
+
if err != nil {
261
+
log.Printf("getting profile data for %s: %s", user.Did, err)
262
+
}
263
+
264
+
i := 0
265
+
var pinnedRepos [6]syntax.ATURI
266
+
for key, values := range r.Form {
267
+
if i >= 6 {
268
+
log.Println("invalid pin update form", err)
269
+
s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
270
+
return
271
+
}
272
+
if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
273
+
aturi, err := syntax.ParseATURI(values[0])
274
+
if err != nil {
275
+
log.Println("invalid profile update form", err)
276
+
s.pages.Notice(w, "update-profile", "Invalid form.")
277
+
return
278
+
}
279
+
pinnedRepos[i] = aturi
280
+
i++
281
+
}
282
+
}
283
+
profile.PinnedRepos = pinnedRepos
284
+
285
+
s.updateProfile(profile, w, r)
286
+
return
287
+
}
288
+
289
+
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
290
+
user := s.oauth.GetUser(r)
291
+
tx, err := s.db.BeginTx(r.Context(), nil)
292
+
if err != nil {
293
+
log.Println("failed to start transaction", err)
294
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
295
+
return
296
+
}
297
+
298
+
client, err := s.oauth.AuthorizedClient(r)
299
+
if err != nil {
300
+
log.Println("failed to get authorized client", err)
301
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
302
+
return
303
+
}
304
+
305
+
// yeah... lexgen dose not support syntax.ATURI in the record for some reason,
306
+
// nor does it support exact size arrays
307
+
var pinnedRepoStrings []string
308
+
for _, r := range profile.PinnedRepos {
309
+
pinnedRepoStrings = append(pinnedRepoStrings, r.String())
310
+
}
311
+
312
+
var vanityStats []string
313
+
for _, v := range profile.Stats {
314
+
vanityStats = append(vanityStats, string(v.Kind))
315
+
}
316
+
317
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
318
+
var cid *string
319
+
if ex != nil {
320
+
cid = ex.Cid
321
+
}
322
+
323
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
324
+
Collection: tangled.ActorProfileNSID,
325
+
Repo: user.Did,
326
+
Rkey: "self",
327
+
Record: &lexutil.LexiconTypeDecoder{
328
+
Val: &tangled.ActorProfile{
329
+
Bluesky: profile.IncludeBluesky,
330
+
Description: &profile.Description,
331
+
Links: profile.Links[:],
332
+
Location: &profile.Location,
333
+
PinnedRepositories: pinnedRepoStrings,
334
+
Stats: vanityStats[:],
335
+
}},
336
+
SwapRecord: cid,
337
+
})
338
+
if err != nil {
339
+
log.Println("failed to update profile", err)
340
+
s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
341
+
return
342
+
}
343
+
344
+
err = db.UpsertProfile(tx, profile)
345
+
if err != nil {
346
+
log.Println("failed to update profile", err)
347
+
s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
348
+
return
349
+
}
350
+
351
+
if !s.config.Core.Dev {
352
+
err = s.posthog.Enqueue(posthog.Capture{
353
+
DistinctId: user.Did,
354
+
Event: "edit_profile",
355
+
})
356
+
if err != nil {
357
+
log.Println("failed to enqueue posthog event:", err)
358
+
}
359
+
}
360
+
361
+
s.pages.HxRedirect(w, "/"+user.Did)
362
+
return
363
+
}
364
+
365
+
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
366
+
user := s.oauth.GetUser(r)
367
+
368
+
profile, err := db.GetProfile(s.db, user.Did)
369
+
if err != nil {
370
+
log.Printf("getting profile data for %s: %s", user.Did, err)
371
+
}
372
+
373
+
s.pages.EditBioFragment(w, pages.EditBioParams{
374
+
LoggedInUser: user,
375
+
Profile: profile,
376
+
})
377
+
}
378
+
379
+
func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
380
+
user := s.oauth.GetUser(r)
381
+
382
+
profile, err := db.GetProfile(s.db, user.Did)
383
+
if err != nil {
384
+
log.Printf("getting profile data for %s: %s", user.Did, err)
385
+
}
386
+
387
+
repos, err := db.GetAllReposByDid(s.db, user.Did)
388
+
if err != nil {
389
+
log.Printf("getting repos for %s: %s", user.Did, err)
390
+
}
391
+
392
+
collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
393
+
if err != nil {
394
+
log.Printf("getting collaborating repos for %s: %s", user.Did, err)
395
+
}
396
+
397
+
allRepos := []pages.PinnedRepo{}
398
+
399
+
for _, r := range repos {
400
+
isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
401
+
allRepos = append(allRepos, pages.PinnedRepo{
402
+
IsPinned: isPinned,
403
+
Repo: r,
404
+
})
405
+
}
406
+
for _, r := range collaboratingRepos {
407
+
isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
408
+
allRepos = append(allRepos, pages.PinnedRepo{
409
+
IsPinned: isPinned,
410
+
Repo: r,
411
+
})
412
+
}
413
+
414
+
var didsToResolve []string
415
+
for _, r := range allRepos {
416
+
didsToResolve = append(didsToResolve, r.Did)
417
+
}
418
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
419
+
didHandleMap := make(map[string]string)
420
+
for _, identity := range resolvedIds {
421
+
if !identity.Handle.IsInvalidHandle() {
422
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
423
+
} else {
424
+
didHandleMap[identity.DID.String()] = identity.DID.String()
425
+
}
426
+
}
427
+
428
+
s.pages.EditPinsFragment(w, pages.EditPinsParams{
429
+
LoggedInUser: user,
430
+
Profile: profile,
431
+
AllRepos: allRepos,
432
+
DidHandleMap: didHandleMap,
433
+
})
109
434
}
+733
-296
appview/state/pull.go
+733
-296
appview/state/pull.go
···
8
8
"io"
9
9
"log"
10
10
"net/http"
11
+
"sort"
11
12
"strconv"
13
+
"strings"
12
14
"time"
13
15
14
16
"tangled.sh/tangled.sh/core/api/tangled"
15
17
"tangled.sh/tangled.sh/core/appview"
16
-
"tangled.sh/tangled.sh/core/appview/auth"
17
18
"tangled.sh/tangled.sh/core/appview/db"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
18
20
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/knotclient"
19
22
"tangled.sh/tangled.sh/core/patchutil"
20
23
"tangled.sh/tangled.sh/core/types"
21
24
25
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
22
26
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
27
"github.com/bluesky-social/indigo/atproto/syntax"
24
28
lexutil "github.com/bluesky-social/indigo/lex/util"
25
29
"github.com/go-chi/chi/v5"
30
+
"github.com/google/uuid"
31
+
"github.com/posthog/posthog-go"
26
32
)
27
33
28
34
// htmx fragment
29
35
func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
30
36
switch r.Method {
31
37
case http.MethodGet:
32
-
user := s.auth.GetUser(r)
38
+
user := s.oauth.GetUser(r)
33
39
f, err := s.fullyResolvedRepo(r)
34
40
if err != nil {
35
41
log.Println("failed to get repo and knot", err)
···
42
48
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
43
49
return
44
50
}
51
+
52
+
// can be nil if this pull is not stacked
53
+
stack, _ := r.Context().Value("stack").(db.Stack)
45
54
46
55
roundNumberStr := chi.URLParam(r, "round")
47
56
roundNumber, err := strconv.Atoi(roundNumberStr)
···
54
63
return
55
64
}
56
65
57
-
mergeCheckResponse := s.mergeCheck(f, pull)
66
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
58
67
resubmitResult := pages.Unknown
59
68
if user.Did == pull.OwnerDid {
60
-
resubmitResult = s.resubmitCheck(f, pull)
69
+
resubmitResult = s.resubmitCheck(f, pull, stack)
61
70
}
62
71
63
72
s.pages.PullActionsFragment(w, pages.PullActionsParams{
···
67
76
RoundNumber: roundNumber,
68
77
MergeCheck: mergeCheckResponse,
69
78
ResubmitCheck: resubmitResult,
79
+
Stack: stack,
70
80
})
71
81
return
72
82
}
73
83
}
74
84
75
85
func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
76
-
user := s.auth.GetUser(r)
86
+
user := s.oauth.GetUser(r)
77
87
f, err := s.fullyResolvedRepo(r)
78
88
if err != nil {
79
89
log.Println("failed to get repo and knot", err)
···
86
96
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
87
97
return
88
98
}
99
+
100
+
// can be nil if this pull is not stacked
101
+
stack, _ := r.Context().Value("stack").(db.Stack)
102
+
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
89
103
90
104
totalIdents := 1
91
105
for _, submission := range pull.Submissions {
···
114
128
}
115
129
}
116
130
117
-
mergeCheckResponse := s.mergeCheck(f, pull)
131
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
118
132
resubmitResult := pages.Unknown
119
133
if user != nil && user.Did == pull.OwnerDid {
120
-
resubmitResult = s.resubmitCheck(f, pull)
134
+
resubmitResult = s.resubmitCheck(f, pull, stack)
121
135
}
122
136
123
137
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
124
-
LoggedInUser: user,
125
-
RepoInfo: f.RepoInfo(s, user),
126
-
DidHandleMap: didHandleMap,
127
-
Pull: pull,
128
-
MergeCheck: mergeCheckResponse,
129
-
ResubmitCheck: resubmitResult,
138
+
LoggedInUser: user,
139
+
RepoInfo: f.RepoInfo(s, user),
140
+
DidHandleMap: didHandleMap,
141
+
Pull: pull,
142
+
Stack: stack,
143
+
AbandonedPulls: abandonedPulls,
144
+
MergeCheck: mergeCheckResponse,
145
+
ResubmitCheck: resubmitResult,
130
146
})
131
147
}
132
148
133
-
func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
149
+
func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
134
150
if pull.State == db.PullMerged {
135
151
return types.MergeCheckResponse{}
136
152
}
···
143
159
}
144
160
}
145
161
146
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
162
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
147
163
if err != nil {
148
164
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
149
165
return types.MergeCheckResponse{
···
151
167
}
152
168
}
153
169
154
-
resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
170
+
patch := pull.LatestPatch()
171
+
if pull.IsStacked() {
172
+
// combine patches of substack
173
+
subStack := stack.Below(pull)
174
+
// collect the portion of the stack that is mergeable
175
+
mergeable := subStack.Mergeable()
176
+
// combine each patch
177
+
patch = mergeable.CombinedPatch()
178
+
}
179
+
180
+
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
155
181
if err != nil {
156
182
log.Println("failed to check for mergeability:", err)
157
183
return types.MergeCheckResponse{
···
190
216
return mergeCheckResponse
191
217
}
192
218
193
-
func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
194
-
if pull.State == db.PullMerged || pull.PullSource == nil {
219
+
func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
220
+
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
195
221
return pages.Unknown
196
222
}
197
223
···
215
241
repoName = f.RepoName
216
242
}
217
243
218
-
us, err := NewUnsignedClient(knot, s.config.Dev)
244
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
219
245
if err != nil {
220
246
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
221
247
return pages.Unknown
222
248
}
223
249
224
-
resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
250
+
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
225
251
if err != nil {
226
252
log.Println("failed to reach knotserver", err)
227
253
return pages.Unknown
228
254
}
229
255
230
-
body, err := io.ReadAll(resp.Body)
231
-
if err != nil {
232
-
log.Printf("error reading response body: %v", err)
233
-
return pages.Unknown
234
-
}
235
-
defer resp.Body.Close()
256
+
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
236
257
237
-
var result types.RepoBranchResponse
238
-
if err := json.Unmarshal(body, &result); err != nil {
239
-
log.Println("failed to parse response:", err)
240
-
return pages.Unknown
258
+
if pull.IsStacked() && stack != nil {
259
+
top := stack[0]
260
+
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
241
261
}
242
262
243
-
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
244
-
if latestSubmission.SourceRev != result.Branch.Hash {
245
-
fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
263
+
log.Println(latestSourceRev, result.Branch.Hash)
264
+
265
+
if latestSourceRev != result.Branch.Hash {
246
266
return pages.ShouldResubmit
247
267
}
248
268
···
250
270
}
251
271
252
272
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
253
-
user := s.auth.GetUser(r)
273
+
user := s.oauth.GetUser(r)
254
274
f, err := s.fullyResolvedRepo(r)
255
275
if err != nil {
256
276
log.Println("failed to get repo and knot", err)
···
264
284
return
265
285
}
266
286
287
+
stack, _ := r.Context().Value("stack").(db.Stack)
288
+
267
289
roundId := chi.URLParam(r, "round")
268
290
roundIdInt, err := strconv.Atoi(roundId)
269
291
if err != nil || roundIdInt >= len(pull.Submissions) {
···
290
312
DidHandleMap: didHandleMap,
291
313
RepoInfo: f.RepoInfo(s, user),
292
314
Pull: pull,
315
+
Stack: stack,
293
316
Round: roundIdInt,
294
317
Submission: pull.Submissions[roundIdInt],
295
318
Diff: &diff,
···
298
321
}
299
322
300
323
func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
301
-
user := s.auth.GetUser(r)
324
+
user := s.oauth.GetUser(r)
302
325
303
326
f, err := s.fullyResolvedRepo(r)
304
327
if err != nil {
···
355
378
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
356
379
357
380
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
358
-
LoggedInUser: s.auth.GetUser(r),
381
+
LoggedInUser: s.oauth.GetUser(r),
359
382
RepoInfo: f.RepoInfo(s, user),
360
383
Pull: pull,
361
384
Round: roundIdInt,
···
397
420
}
398
421
399
422
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
400
-
user := s.auth.GetUser(r)
423
+
user := s.oauth.GetUser(r)
401
424
params := r.URL.Query()
402
425
403
426
state := db.PullOpen
···
414
437
return
415
438
}
416
439
417
-
pulls, err := db.GetPulls(s.db, f.RepoAt, state)
440
+
pulls, err := db.GetPulls(
441
+
s.db,
442
+
db.FilterEq("repo_at", f.RepoAt),
443
+
db.FilterEq("state", state),
444
+
)
418
445
if err != nil {
419
446
log.Println("failed to get pulls", err)
420
447
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
···
451
478
}
452
479
453
480
s.pages.RepoPulls(w, pages.RepoPullsParams{
454
-
LoggedInUser: s.auth.GetUser(r),
481
+
LoggedInUser: s.oauth.GetUser(r),
455
482
RepoInfo: f.RepoInfo(s, user),
456
483
Pulls: pulls,
457
484
DidHandleMap: didHandleMap,
···
461
488
}
462
489
463
490
func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
464
-
user := s.auth.GetUser(r)
491
+
user := s.oauth.GetUser(r)
465
492
f, err := s.fullyResolvedRepo(r)
466
493
if err != nil {
467
494
log.Println("failed to get repo and knot", err)
···
519
546
}
520
547
521
548
atUri := f.RepoAt.String()
522
-
client, _ := s.auth.AuthorizedClient(r)
523
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
549
+
client, err := s.oauth.AuthorizedClient(r)
550
+
if err != nil {
551
+
log.Println("failed to get authorized client", err)
552
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
553
+
return
554
+
}
555
+
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
524
556
Collection: tangled.RepoPullCommentNSID,
525
557
Repo: user.Did,
526
558
Rkey: appview.TID(),
···
562
594
return
563
595
}
564
596
597
+
if !s.config.Core.Dev {
598
+
err = s.posthog.Enqueue(posthog.Capture{
599
+
DistinctId: user.Did,
600
+
Event: "new_pull_comment",
601
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
602
+
})
603
+
if err != nil {
604
+
log.Println("failed to enqueue posthog event:", err)
605
+
}
606
+
}
607
+
565
608
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
566
609
return
567
610
}
568
611
}
569
612
570
613
func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
571
-
user := s.auth.GetUser(r)
614
+
user := s.oauth.GetUser(r)
572
615
f, err := s.fullyResolvedRepo(r)
573
616
if err != nil {
574
617
log.Println("failed to get repo and knot", err)
···
577
620
578
621
switch r.Method {
579
622
case http.MethodGet:
580
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
623
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
581
624
if err != nil {
582
625
log.Printf("failed to create unsigned client for %s", f.Knot)
583
626
s.pages.Error503(w)
584
627
return
585
628
}
586
629
587
-
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
630
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
588
631
if err != nil {
589
632
log.Println("failed to reach knotserver", err)
590
-
return
591
-
}
592
-
593
-
body, err := io.ReadAll(resp.Body)
594
-
if err != nil {
595
-
log.Printf("Error reading response body: %v", err)
596
-
return
597
-
}
598
-
599
-
var result types.RepoBranchesResponse
600
-
err = json.Unmarshal(body, &result)
601
-
if err != nil {
602
-
log.Println("failed to parse response:", err)
603
633
return
604
634
}
605
635
···
608
638
RepoInfo: f.RepoInfo(s, user),
609
639
Branches: result.Branches,
610
640
})
641
+
611
642
case http.MethodPost:
612
643
title := r.FormValue("title")
613
644
body := r.FormValue("body")
···
626
657
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
627
658
isForkBased := fromFork != "" && sourceBranch != ""
628
659
isPatchBased := patch != "" && !isBranchBased && !isForkBased
660
+
isStacked := r.FormValue("isStacked") == "on"
629
661
630
662
if isPatchBased && !patchutil.IsFormatPatch(patch) {
631
663
if title == "" {
···
646
678
return
647
679
}
648
680
649
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
681
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
650
682
if err != nil {
651
683
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
652
684
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
···
671
703
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
672
704
return
673
705
}
674
-
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
706
+
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
675
707
} else if isForkBased {
676
708
if !caps.PullRequests.ForkSubmissions {
677
709
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
678
710
return
679
711
}
680
-
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
712
+
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
681
713
} else if isPatchBased {
682
714
if !caps.PullRequests.PatchSubmissions {
683
715
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
684
716
return
685
717
}
686
-
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
718
+
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
687
719
}
688
720
return
689
721
}
690
722
}
691
723
692
-
func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
724
+
func (s *State) handleBranchBasedPull(
725
+
w http.ResponseWriter,
726
+
r *http.Request,
727
+
f *FullyResolvedRepo,
728
+
user *oauth.User,
729
+
title,
730
+
body,
731
+
targetBranch,
732
+
sourceBranch string,
733
+
isStacked bool,
734
+
) {
693
735
pullSource := &db.PullSource{
694
736
Branch: sourceBranch,
695
737
}
···
698
740
}
699
741
700
742
// Generate a patch using /compare
701
-
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
743
+
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
702
744
if err != nil {
703
745
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
704
746
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
720
762
return
721
763
}
722
764
723
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
765
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
724
766
}
725
767
726
-
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
768
+
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
727
769
if !patchutil.IsPatchValid(patch) {
728
770
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
729
771
return
730
772
}
731
773
732
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
774
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
733
775
}
734
776
735
-
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
777
+
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
736
778
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
737
779
if errors.Is(err, sql.ErrNoRows) {
738
780
s.pages.Notice(w, "pull", "No such fork.")
···
750
792
return
751
793
}
752
794
753
-
sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
795
+
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
754
796
if err != nil {
755
797
log.Println("failed to create signed client:", err)
756
798
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
757
799
return
758
800
}
759
801
760
-
us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
802
+
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
761
803
if err != nil {
762
804
log.Println("failed to create unsigned client:", err)
763
805
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
809
851
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
810
852
Branch: sourceBranch,
811
853
RepoAt: &forkAtUri,
812
-
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
854
+
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
813
855
}
814
856
815
857
func (s *State) createPullRequest(
816
858
w http.ResponseWriter,
817
859
r *http.Request,
818
860
f *FullyResolvedRepo,
819
-
user *auth.User,
861
+
user *oauth.User,
820
862
title, body, targetBranch string,
821
863
patch string,
822
864
sourceRev string,
823
865
pullSource *db.PullSource,
824
866
recordPullSource *tangled.RepoPull_Source,
867
+
isStacked bool,
825
868
) {
869
+
if isStacked {
870
+
// creates a series of PRs, each linking to the previous, identified by jj's change-id
871
+
s.createStackedPulLRequest(
872
+
w,
873
+
r,
874
+
f,
875
+
user,
876
+
targetBranch,
877
+
patch,
878
+
sourceRev,
879
+
pullSource,
880
+
)
881
+
return
882
+
}
883
+
884
+
client, err := s.oauth.AuthorizedClient(r)
885
+
if err != nil {
886
+
log.Println("failed to get authorized client", err)
887
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
888
+
return
889
+
}
890
+
826
891
tx, err := s.db.BeginTx(r.Context(), nil)
827
892
if err != nil {
828
893
log.Println("failed to start tx")
···
870
935
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
871
936
return
872
937
}
873
-
client, _ := s.auth.AuthorizedClient(r)
874
-
pullId, err := db.NextPullId(s.db, f.RepoAt)
938
+
pullId, err := db.NextPullId(tx, f.RepoAt)
875
939
if err != nil {
876
940
log.Println("failed to get pull id", err)
877
941
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
878
942
return
879
943
}
880
944
881
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
945
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
882
946
Collection: tangled.RepoPullNSID,
883
947
Repo: user.Did,
884
948
Rkey: rkey,
···
893
957
},
894
958
},
895
959
})
960
+
if err != nil {
961
+
log.Println("failed to create pull request", err)
962
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
963
+
return
964
+
}
965
+
966
+
if err = tx.Commit(); err != nil {
967
+
log.Println("failed to create pull request", err)
968
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
969
+
return
970
+
}
971
+
972
+
if !s.config.Core.Dev {
973
+
err = s.posthog.Enqueue(posthog.Capture{
974
+
DistinctId: user.Did,
975
+
Event: "new_pull",
976
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
977
+
})
978
+
if err != nil {
979
+
log.Println("failed to enqueue posthog event:", err)
980
+
}
981
+
}
982
+
983
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
984
+
}
985
+
986
+
func (s *State) createStackedPulLRequest(
987
+
w http.ResponseWriter,
988
+
r *http.Request,
989
+
f *FullyResolvedRepo,
990
+
user *oauth.User,
991
+
targetBranch string,
992
+
patch string,
993
+
sourceRev string,
994
+
pullSource *db.PullSource,
995
+
) {
996
+
// run some necessary checks for stacked-prs first
997
+
998
+
// must be branch or fork based
999
+
if sourceRev == "" {
1000
+
log.Println("stacked PR from patch-based pull")
1001
+
s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1002
+
return
1003
+
}
896
1004
1005
+
formatPatches, err := patchutil.ExtractPatches(patch)
897
1006
if err != nil {
1007
+
log.Println("failed to extract patches", err)
1008
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1009
+
return
1010
+
}
1011
+
1012
+
// must have atleast 1 patch to begin with
1013
+
if len(formatPatches) == 0 {
1014
+
log.Println("empty patches")
1015
+
s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1016
+
return
1017
+
}
1018
+
1019
+
// build a stack out of this patch
1020
+
stackId := uuid.New()
1021
+
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1022
+
if err != nil {
1023
+
log.Println("failed to create stack", err)
1024
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1025
+
return
1026
+
}
1027
+
1028
+
client, err := s.oauth.AuthorizedClient(r)
1029
+
if err != nil {
1030
+
log.Println("failed to get authorized client", err)
1031
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1032
+
return
1033
+
}
1034
+
1035
+
// apply all record creations at once
1036
+
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1037
+
for _, p := range stack {
1038
+
record := p.AsRecord()
1039
+
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1040
+
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1041
+
Collection: tangled.RepoPullNSID,
1042
+
Rkey: &p.Rkey,
1043
+
Value: &lexutil.LexiconTypeDecoder{
1044
+
Val: &record,
1045
+
},
1046
+
},
1047
+
}
1048
+
writes = append(writes, &write)
1049
+
}
1050
+
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1051
+
Repo: user.Did,
1052
+
Writes: writes,
1053
+
})
1054
+
if err != nil {
1055
+
log.Println("failed to create stacked pull request", err)
1056
+
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1057
+
return
1058
+
}
1059
+
1060
+
// create all pulls at once
1061
+
tx, err := s.db.BeginTx(r.Context(), nil)
1062
+
if err != nil {
1063
+
log.Println("failed to start tx")
1064
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1065
+
return
1066
+
}
1067
+
defer tx.Rollback()
1068
+
1069
+
for _, p := range stack {
1070
+
err = db.NewPull(tx, p)
1071
+
if err != nil {
1072
+
log.Println("failed to create pull request", err)
1073
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1074
+
return
1075
+
}
1076
+
}
1077
+
1078
+
if err = tx.Commit(); err != nil {
898
1079
log.Println("failed to create pull request", err)
899
1080
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
900
1081
return
901
1082
}
902
1083
903
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1084
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
904
1085
}
905
1086
906
1087
func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
···
929
1110
}
930
1111
931
1112
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
932
-
user := s.auth.GetUser(r)
1113
+
user := s.oauth.GetUser(r)
933
1114
f, err := s.fullyResolvedRepo(r)
934
1115
if err != nil {
935
1116
log.Println("failed to get repo and knot", err)
···
942
1123
}
943
1124
944
1125
func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
945
-
user := s.auth.GetUser(r)
1126
+
user := s.oauth.GetUser(r)
946
1127
f, err := s.fullyResolvedRepo(r)
947
1128
if err != nil {
948
1129
log.Println("failed to get repo and knot", err)
949
1130
return
950
1131
}
951
1132
952
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
1133
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
953
1134
if err != nil {
954
1135
log.Printf("failed to create unsigned client for %s", f.Knot)
955
1136
s.pages.Error503(w)
956
1137
return
957
1138
}
958
1139
959
-
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1140
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
960
1141
if err != nil {
961
1142
log.Println("failed to reach knotserver", err)
962
1143
return
963
1144
}
964
1145
965
-
body, err := io.ReadAll(resp.Body)
966
-
if err != nil {
967
-
log.Printf("Error reading response body: %v", err)
968
-
return
969
-
}
1146
+
branches := result.Branches
1147
+
sort.Slice(branches, func(i int, j int) bool {
1148
+
return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1149
+
})
970
1150
971
-
var result types.RepoBranchesResponse
972
-
err = json.Unmarshal(body, &result)
973
-
if err != nil {
974
-
log.Println("failed to parse response:", err)
975
-
return
1151
+
withoutDefault := []types.Branch{}
1152
+
for _, b := range branches {
1153
+
if b.IsDefault {
1154
+
continue
1155
+
}
1156
+
withoutDefault = append(withoutDefault, b)
976
1157
}
977
1158
978
1159
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
979
1160
RepoInfo: f.RepoInfo(s, user),
980
-
Branches: result.Branches,
1161
+
Branches: withoutDefault,
981
1162
})
982
1163
}
983
1164
984
1165
func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
985
-
user := s.auth.GetUser(r)
1166
+
user := s.oauth.GetUser(r)
986
1167
f, err := s.fullyResolvedRepo(r)
987
1168
if err != nil {
988
1169
log.Println("failed to get repo and knot", err)
···
1002
1183
}
1003
1184
1004
1185
func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1005
-
user := s.auth.GetUser(r)
1186
+
user := s.oauth.GetUser(r)
1006
1187
1007
1188
f, err := s.fullyResolvedRepo(r)
1008
1189
if err != nil {
···
1019
1200
return
1020
1201
}
1021
1202
1022
-
sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
1203
+
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1023
1204
if err != nil {
1024
1205
log.Printf("failed to create unsigned client for %s", repo.Knot)
1025
1206
s.pages.Error503(w)
1026
1207
return
1027
1208
}
1028
1209
1029
-
sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1210
+
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1030
1211
if err != nil {
1031
1212
log.Println("failed to reach knotserver for source branches", err)
1032
1213
return
1033
1214
}
1034
1215
1035
-
sourceBody, err := io.ReadAll(sourceResp.Body)
1036
-
if err != nil {
1037
-
log.Println("failed to read source response body", err)
1038
-
return
1039
-
}
1040
-
defer sourceResp.Body.Close()
1041
-
1042
-
var sourceResult types.RepoBranchesResponse
1043
-
err = json.Unmarshal(sourceBody, &sourceResult)
1044
-
if err != nil {
1045
-
log.Println("failed to parse source branches response:", err)
1046
-
return
1047
-
}
1048
-
1049
-
targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1216
+
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1050
1217
if err != nil {
1051
1218
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1052
1219
s.pages.Error503(w)
1053
1220
return
1054
1221
}
1055
1222
1056
-
targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1223
+
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1057
1224
if err != nil {
1058
1225
log.Println("failed to reach knotserver for target branches", err)
1059
1226
return
1060
1227
}
1061
1228
1062
-
targetBody, err := io.ReadAll(targetResp.Body)
1063
-
if err != nil {
1064
-
log.Println("failed to read target response body", err)
1065
-
return
1066
-
}
1067
-
defer targetResp.Body.Close()
1068
-
1069
-
var targetResult types.RepoBranchesResponse
1070
-
err = json.Unmarshal(targetBody, &targetResult)
1071
-
if err != nil {
1072
-
log.Println("failed to parse target branches response:", err)
1073
-
return
1074
-
}
1229
+
sourceBranches := sourceResult.Branches
1230
+
sort.Slice(sourceBranches, func(i int, j int) bool {
1231
+
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1232
+
})
1075
1233
1076
1234
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1077
1235
RepoInfo: f.RepoInfo(s, user),
···
1081
1239
}
1082
1240
1083
1241
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1084
-
user := s.auth.GetUser(r)
1242
+
user := s.oauth.GetUser(r)
1085
1243
f, err := s.fullyResolvedRepo(r)
1086
1244
if err != nil {
1087
1245
log.Println("failed to get repo and knot", err)
···
1117
1275
}
1118
1276
1119
1277
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1120
-
user := s.auth.GetUser(r)
1278
+
user := s.oauth.GetUser(r)
1121
1279
1122
1280
pull, ok := r.Context().Value("pull").(*db.Pull)
1123
1281
if !ok {
···
1140
1298
1141
1299
patch := r.FormValue("patch")
1142
1300
1143
-
if err = validateResubmittedPatch(pull, patch); err != nil {
1144
-
s.pages.Notice(w, "resubmit-error", err.Error())
1145
-
return
1146
-
}
1147
-
1148
-
tx, err := s.db.BeginTx(r.Context(), nil)
1149
-
if err != nil {
1150
-
log.Println("failed to start tx")
1151
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1152
-
return
1153
-
}
1154
-
defer tx.Rollback()
1155
-
1156
-
err = db.ResubmitPull(tx, pull, patch, "")
1157
-
if err != nil {
1158
-
log.Println("failed to resubmit pull request", err)
1159
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1160
-
return
1161
-
}
1162
-
client, _ := s.auth.AuthorizedClient(r)
1163
-
1164
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1165
-
if err != nil {
1166
-
// failed to get record
1167
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1168
-
return
1169
-
}
1170
-
1171
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1172
-
Collection: tangled.RepoPullNSID,
1173
-
Repo: user.Did,
1174
-
Rkey: pull.Rkey,
1175
-
SwapRecord: ex.Cid,
1176
-
Record: &lexutil.LexiconTypeDecoder{
1177
-
Val: &tangled.RepoPull{
1178
-
Title: pull.Title,
1179
-
PullId: int64(pull.PullId),
1180
-
TargetRepo: string(f.RepoAt),
1181
-
TargetBranch: pull.TargetBranch,
1182
-
Patch: patch, // new patch
1183
-
},
1184
-
},
1185
-
})
1186
-
if err != nil {
1187
-
log.Println("failed to update record", err)
1188
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1189
-
return
1190
-
}
1191
-
1192
-
if err = tx.Commit(); err != nil {
1193
-
log.Println("failed to commit transaction", err)
1194
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1195
-
return
1196
-
}
1197
-
1198
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1199
-
return
1301
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1200
1302
}
1201
1303
1202
1304
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1203
-
user := s.auth.GetUser(r)
1305
+
user := s.oauth.GetUser(r)
1204
1306
1205
1307
pull, ok := r.Context().Value("pull").(*db.Pull)
1206
1308
if !ok {
···
1227
1329
return
1228
1330
}
1229
1331
1230
-
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1332
+
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1231
1333
if err != nil {
1232
1334
log.Printf("failed to create client for %s: %s", f.Knot, err)
1233
1335
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1244
1346
sourceRev := comparison.Rev2
1245
1347
patch := comparison.Patch
1246
1348
1247
-
if err = validateResubmittedPatch(pull, patch); err != nil {
1248
-
s.pages.Notice(w, "resubmit-error", err.Error())
1249
-
return
1250
-
}
1251
-
1252
-
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1253
-
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1254
-
return
1255
-
}
1256
-
1257
-
tx, err := s.db.BeginTx(r.Context(), nil)
1258
-
if err != nil {
1259
-
log.Println("failed to start tx")
1260
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1261
-
return
1262
-
}
1263
-
defer tx.Rollback()
1264
-
1265
-
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1266
-
if err != nil {
1267
-
log.Println("failed to create pull request", err)
1268
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1269
-
return
1270
-
}
1271
-
client, _ := s.auth.AuthorizedClient(r)
1272
-
1273
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1274
-
if err != nil {
1275
-
// failed to get record
1276
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1277
-
return
1278
-
}
1279
-
1280
-
recordPullSource := &tangled.RepoPull_Source{
1281
-
Branch: pull.PullSource.Branch,
1282
-
}
1283
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1284
-
Collection: tangled.RepoPullNSID,
1285
-
Repo: user.Did,
1286
-
Rkey: pull.Rkey,
1287
-
SwapRecord: ex.Cid,
1288
-
Record: &lexutil.LexiconTypeDecoder{
1289
-
Val: &tangled.RepoPull{
1290
-
Title: pull.Title,
1291
-
PullId: int64(pull.PullId),
1292
-
TargetRepo: string(f.RepoAt),
1293
-
TargetBranch: pull.TargetBranch,
1294
-
Patch: patch, // new patch
1295
-
Source: recordPullSource,
1296
-
},
1297
-
},
1298
-
})
1299
-
if err != nil {
1300
-
log.Println("failed to update record", err)
1301
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1302
-
return
1303
-
}
1304
-
1305
-
if err = tx.Commit(); err != nil {
1306
-
log.Println("failed to commit transaction", err)
1307
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1308
-
return
1309
-
}
1310
-
1311
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1312
-
return
1349
+
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1313
1350
}
1314
1351
1315
1352
func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1316
-
user := s.auth.GetUser(r)
1353
+
user := s.oauth.GetUser(r)
1317
1354
1318
1355
pull, ok := r.Context().Value("pull").(*db.Pull)
1319
1356
if !ok {
···
1342
1379
}
1343
1380
1344
1381
// extract patch by performing compare
1345
-
ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1382
+
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1346
1383
if err != nil {
1347
1384
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1348
1385
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1357
1394
}
1358
1395
1359
1396
// update the hidden tracking branch to latest
1360
-
signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1397
+
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1361
1398
if err != nil {
1362
1399
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1363
1400
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
1382
1419
sourceRev := comparison.Rev2
1383
1420
patch := comparison.Patch
1384
1421
1385
-
if err = validateResubmittedPatch(pull, patch); err != nil {
1422
+
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1423
+
}
1424
+
1425
+
// validate a resubmission against a pull request
1426
+
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1427
+
if patch == "" {
1428
+
return fmt.Errorf("Patch is empty.")
1429
+
}
1430
+
1431
+
if patch == pull.LatestPatch() {
1432
+
return fmt.Errorf("Patch is identical to previous submission.")
1433
+
}
1434
+
1435
+
if !patchutil.IsPatchValid(patch) {
1436
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1437
+
}
1438
+
1439
+
return nil
1440
+
}
1441
+
1442
+
func (s *State) resubmitPullHelper(
1443
+
w http.ResponseWriter,
1444
+
r *http.Request,
1445
+
f *FullyResolvedRepo,
1446
+
user *oauth.User,
1447
+
pull *db.Pull,
1448
+
patch string,
1449
+
sourceRev string,
1450
+
) {
1451
+
if pull.IsStacked() {
1452
+
log.Println("resubmitting stacked PR")
1453
+
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1454
+
return
1455
+
}
1456
+
1457
+
if err := validateResubmittedPatch(pull, patch); err != nil {
1386
1458
s.pages.Notice(w, "resubmit-error", err.Error())
1387
1459
return
1388
1460
}
1389
1461
1390
-
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1391
-
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1392
-
return
1462
+
// validate sourceRev if branch/fork based
1463
+
if pull.IsBranchBased() || pull.IsForkBased() {
1464
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1465
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1466
+
return
1467
+
}
1393
1468
}
1394
1469
1395
1470
tx, err := s.db.BeginTx(r.Context(), nil)
···
1406
1481
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1407
1482
return
1408
1483
}
1409
-
client, _ := s.auth.AuthorizedClient(r)
1484
+
client, err := s.oauth.AuthorizedClient(r)
1485
+
if err != nil {
1486
+
log.Println("failed to authorize client")
1487
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1488
+
return
1489
+
}
1410
1490
1411
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1491
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1412
1492
if err != nil {
1413
1493
// failed to get record
1414
1494
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1415
1495
return
1416
1496
}
1417
1497
1418
-
repoAt := pull.PullSource.RepoAt.String()
1419
-
recordPullSource := &tangled.RepoPull_Source{
1420
-
Branch: pull.PullSource.Branch,
1421
-
Repo: &repoAt,
1498
+
var recordPullSource *tangled.RepoPull_Source
1499
+
if pull.IsBranchBased() {
1500
+
recordPullSource = &tangled.RepoPull_Source{
1501
+
Branch: pull.PullSource.Branch,
1502
+
}
1422
1503
}
1423
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1504
+
if pull.IsForkBased() {
1505
+
repoAt := pull.PullSource.RepoAt.String()
1506
+
recordPullSource = &tangled.RepoPull_Source{
1507
+
Branch: pull.PullSource.Branch,
1508
+
Repo: &repoAt,
1509
+
}
1510
+
}
1511
+
1512
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1424
1513
Collection: tangled.RepoPullNSID,
1425
1514
Repo: user.Did,
1426
1515
Rkey: pull.Rkey,
···
1452
1541
return
1453
1542
}
1454
1543
1455
-
// validate a resubmission against a pull request
1456
-
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1457
-
if patch == "" {
1458
-
return fmt.Errorf("Patch is empty.")
1544
+
func (s *State) resubmitStackedPullHelper(
1545
+
w http.ResponseWriter,
1546
+
r *http.Request,
1547
+
f *FullyResolvedRepo,
1548
+
user *oauth.User,
1549
+
pull *db.Pull,
1550
+
patch string,
1551
+
stackId string,
1552
+
) {
1553
+
targetBranch := pull.TargetBranch
1554
+
1555
+
origStack, _ := r.Context().Value("stack").(db.Stack)
1556
+
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1557
+
if err != nil {
1558
+
log.Println("failed to create resubmitted stack", err)
1559
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1560
+
return
1561
+
}
1562
+
1563
+
// find the diff between the stacks, first, map them by changeId
1564
+
origById := make(map[string]*db.Pull)
1565
+
newById := make(map[string]*db.Pull)
1566
+
for _, p := range origStack {
1567
+
origById[p.ChangeId] = p
1568
+
}
1569
+
for _, p := range newStack {
1570
+
newById[p.ChangeId] = p
1459
1571
}
1460
1572
1461
-
if patch == pull.LatestPatch() {
1462
-
return fmt.Errorf("Patch is identical to previous submission.")
1573
+
// commits that got deleted: corresponding pull is closed
1574
+
// commits that got added: new pull is created
1575
+
// commits that got updated: corresponding pull is resubmitted & new round begins
1576
+
//
1577
+
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1578
+
additions := make(map[string]*db.Pull)
1579
+
deletions := make(map[string]*db.Pull)
1580
+
unchanged := make(map[string]struct{})
1581
+
updated := make(map[string]struct{})
1582
+
1583
+
// pulls in orignal stack but not in new one
1584
+
for _, op := range origStack {
1585
+
if _, ok := newById[op.ChangeId]; !ok {
1586
+
deletions[op.ChangeId] = op
1587
+
}
1463
1588
}
1464
1589
1465
-
if !patchutil.IsPatchValid(patch) {
1466
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1590
+
// pulls in new stack but not in original one
1591
+
for _, np := range newStack {
1592
+
if _, ok := origById[np.ChangeId]; !ok {
1593
+
additions[np.ChangeId] = np
1594
+
}
1467
1595
}
1468
1596
1469
-
return nil
1597
+
// NOTE: this loop can be written in any of above blocks,
1598
+
// but is written separately in the interest of simpler code
1599
+
for _, np := range newStack {
1600
+
if op, ok := origById[np.ChangeId]; ok {
1601
+
// pull exists in both stacks
1602
+
// TODO: can we avoid reparse?
1603
+
origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1604
+
newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1605
+
1606
+
origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1607
+
newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1608
+
1609
+
patchutil.SortPatch(newFiles)
1610
+
patchutil.SortPatch(origFiles)
1611
+
1612
+
// text content of patch may be identical, but a jj rebase might have forwarded it
1613
+
//
1614
+
// we still need to update the hash in submission.Patch and submission.SourceRev
1615
+
if patchutil.Equal(newFiles, origFiles) &&
1616
+
origHeader.Title == newHeader.Title &&
1617
+
origHeader.Body == newHeader.Body {
1618
+
unchanged[op.ChangeId] = struct{}{}
1619
+
} else {
1620
+
updated[op.ChangeId] = struct{}{}
1621
+
}
1622
+
}
1623
+
}
1624
+
1625
+
tx, err := s.db.Begin()
1626
+
if err != nil {
1627
+
log.Println("failed to start transaction", err)
1628
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1629
+
return
1630
+
}
1631
+
defer tx.Rollback()
1632
+
1633
+
// pds updates to make
1634
+
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1635
+
1636
+
// deleted pulls are marked as deleted in the DB
1637
+
for _, p := range deletions {
1638
+
err := db.DeletePull(tx, p.RepoAt, p.PullId)
1639
+
if err != nil {
1640
+
log.Println("failed to delete pull", err, p.PullId)
1641
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1642
+
return
1643
+
}
1644
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1645
+
RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
1646
+
Collection: tangled.RepoPullNSID,
1647
+
Rkey: p.Rkey,
1648
+
},
1649
+
})
1650
+
}
1651
+
1652
+
// new pulls are created
1653
+
for _, p := range additions {
1654
+
err := db.NewPull(tx, p)
1655
+
if err != nil {
1656
+
log.Println("failed to create pull", err, p.PullId)
1657
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1658
+
return
1659
+
}
1660
+
1661
+
record := p.AsRecord()
1662
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1663
+
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1664
+
Collection: tangled.RepoPullNSID,
1665
+
Rkey: &p.Rkey,
1666
+
Value: &lexutil.LexiconTypeDecoder{
1667
+
Val: &record,
1668
+
},
1669
+
},
1670
+
})
1671
+
}
1672
+
1673
+
// updated pulls are, well, updated; to start a new round
1674
+
for id := range updated {
1675
+
op, _ := origById[id]
1676
+
np, _ := newById[id]
1677
+
1678
+
submission := np.Submissions[np.LastRoundNumber()]
1679
+
1680
+
// resubmit the old pull
1681
+
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1682
+
1683
+
if err != nil {
1684
+
log.Println("failed to update pull", err, op.PullId)
1685
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1686
+
return
1687
+
}
1688
+
1689
+
record := op.AsRecord()
1690
+
record.Patch = submission.Patch
1691
+
1692
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1693
+
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1694
+
Collection: tangled.RepoPullNSID,
1695
+
Rkey: op.Rkey,
1696
+
Value: &lexutil.LexiconTypeDecoder{
1697
+
Val: &record,
1698
+
},
1699
+
},
1700
+
})
1701
+
}
1702
+
1703
+
// unchanged pulls are edited without starting a new round
1704
+
//
1705
+
// update source-revs & patches without advancing rounds
1706
+
for changeId := range unchanged {
1707
+
op, _ := origById[changeId]
1708
+
np, _ := newById[changeId]
1709
+
1710
+
origSubmission := op.Submissions[op.LastRoundNumber()]
1711
+
newSubmission := np.Submissions[np.LastRoundNumber()]
1712
+
1713
+
log.Println("moving unchanged change id : ", changeId)
1714
+
1715
+
err := db.UpdatePull(
1716
+
tx,
1717
+
newSubmission.Patch,
1718
+
newSubmission.SourceRev,
1719
+
db.FilterEq("id", origSubmission.ID),
1720
+
)
1721
+
1722
+
if err != nil {
1723
+
log.Println("failed to update pull", err, op.PullId)
1724
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1725
+
return
1726
+
}
1727
+
1728
+
record := op.AsRecord()
1729
+
record.Patch = newSubmission.Patch
1730
+
1731
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1732
+
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1733
+
Collection: tangled.RepoPullNSID,
1734
+
Rkey: op.Rkey,
1735
+
Value: &lexutil.LexiconTypeDecoder{
1736
+
Val: &record,
1737
+
},
1738
+
},
1739
+
})
1740
+
}
1741
+
1742
+
// update parent-change-id relations for the entire stack
1743
+
for _, p := range newStack {
1744
+
err := db.SetPullParentChangeId(
1745
+
tx,
1746
+
p.ParentChangeId,
1747
+
// these should be enough filters to be unique per-stack
1748
+
db.FilterEq("repo_at", p.RepoAt.String()),
1749
+
db.FilterEq("owner_did", p.OwnerDid),
1750
+
db.FilterEq("change_id", p.ChangeId),
1751
+
)
1752
+
1753
+
if err != nil {
1754
+
log.Println("failed to update pull", err, p.PullId)
1755
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1756
+
return
1757
+
}
1758
+
}
1759
+
1760
+
err = tx.Commit()
1761
+
if err != nil {
1762
+
log.Println("failed to resubmit pull", err)
1763
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1764
+
return
1765
+
}
1766
+
1767
+
client, err := s.oauth.AuthorizedClient(r)
1768
+
if err != nil {
1769
+
log.Println("failed to authorize client")
1770
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1771
+
return
1772
+
}
1773
+
1774
+
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1775
+
Repo: user.Did,
1776
+
Writes: writes,
1777
+
})
1778
+
if err != nil {
1779
+
log.Println("failed to create stacked pull request", err)
1780
+
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1781
+
return
1782
+
}
1783
+
1784
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1785
+
return
1470
1786
}
1471
1787
1472
1788
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
···
1480
1796
pull, ok := r.Context().Value("pull").(*db.Pull)
1481
1797
if !ok {
1482
1798
log.Println("failed to get pull")
1483
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1799
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1484
1800
return
1485
1801
}
1486
1802
1803
+
var pullsToMerge db.Stack
1804
+
pullsToMerge = append(pullsToMerge, pull)
1805
+
if pull.IsStacked() {
1806
+
stack, ok := r.Context().Value("stack").(db.Stack)
1807
+
if !ok {
1808
+
log.Println("failed to get stack")
1809
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1810
+
return
1811
+
}
1812
+
1813
+
// combine patches of substack
1814
+
subStack := stack.StrictlyBelow(pull)
1815
+
// collect the portion of the stack that is mergeable
1816
+
mergeable := subStack.Mergeable()
1817
+
// add to total patch
1818
+
pullsToMerge = append(pullsToMerge, mergeable...)
1819
+
}
1820
+
1821
+
patch := pullsToMerge.CombinedPatch()
1822
+
1487
1823
secret, err := db.GetRegistrationKey(s.db, f.Knot)
1488
1824
if err != nil {
1489
1825
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
···
1503
1839
log.Printf("failed to get primary email: %s", err)
1504
1840
}
1505
1841
1506
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1842
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1507
1843
if err != nil {
1508
1844
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1509
1845
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1511
1847
}
1512
1848
1513
1849
// Merge the pull request
1514
-
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1850
+
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1515
1851
if err != nil {
1516
1852
log.Printf("failed to merge pull request: %s", err)
1517
1853
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1518
1854
return
1519
1855
}
1520
1856
1521
-
if resp.StatusCode == http.StatusOK {
1522
-
err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1857
+
if resp.StatusCode != http.StatusOK {
1858
+
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1859
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1860
+
return
1861
+
}
1862
+
1863
+
tx, err := s.db.Begin()
1864
+
if err != nil {
1865
+
log.Println("failed to start transcation", err)
1866
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1867
+
return
1868
+
}
1869
+
defer tx.Rollback()
1870
+
1871
+
for _, p := range pullsToMerge {
1872
+
err := db.MergePull(tx, f.RepoAt, p.PullId)
1523
1873
if err != nil {
1524
1874
log.Printf("failed to update pull request status in database: %s", err)
1525
1875
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1526
1876
return
1527
1877
}
1528
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1529
-
} else {
1530
-
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1878
+
}
1879
+
1880
+
err = tx.Commit()
1881
+
if err != nil {
1882
+
// TODO: this is unsound, we should also revert the merge from the knotserver here
1883
+
log.Printf("failed to update pull request status in database: %s", err)
1531
1884
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1885
+
return
1532
1886
}
1887
+
1888
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1533
1889
}
1534
1890
1535
1891
func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1536
-
user := s.auth.GetUser(r)
1892
+
user := s.oauth.GetUser(r)
1537
1893
1538
1894
f, err := s.fullyResolvedRepo(r)
1539
1895
if err != nil {
···
1566
1922
s.pages.Notice(w, "pull-close", "Failed to close pull.")
1567
1923
return
1568
1924
}
1925
+
defer tx.Rollback()
1569
1926
1570
-
// Close the pull in the database
1571
-
err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1572
-
if err != nil {
1573
-
log.Println("failed to close pull", err)
1574
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
1575
-
return
1927
+
var pullsToClose []*db.Pull
1928
+
pullsToClose = append(pullsToClose, pull)
1929
+
1930
+
// if this PR is stacked, then we want to close all PRs below this one on the stack
1931
+
if pull.IsStacked() {
1932
+
stack := r.Context().Value("stack").(db.Stack)
1933
+
subStack := stack.StrictlyBelow(pull)
1934
+
pullsToClose = append(pullsToClose, subStack...)
1935
+
}
1936
+
1937
+
for _, p := range pullsToClose {
1938
+
// Close the pull in the database
1939
+
err = db.ClosePull(tx, f.RepoAt, p.PullId)
1940
+
if err != nil {
1941
+
log.Println("failed to close pull", err)
1942
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
1943
+
return
1944
+
}
1576
1945
}
1577
1946
1578
1947
// Commit the transaction
···
1587
1956
}
1588
1957
1589
1958
func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1590
-
user := s.auth.GetUser(r)
1959
+
user := s.oauth.GetUser(r)
1591
1960
1592
1961
f, err := s.fullyResolvedRepo(r)
1593
1962
if err != nil {
···
1621
1990
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1622
1991
return
1623
1992
}
1993
+
defer tx.Rollback()
1624
1994
1625
-
// Reopen the pull in the database
1626
-
err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1627
-
if err != nil {
1628
-
log.Println("failed to reopen pull", err)
1629
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1630
-
return
1995
+
var pullsToReopen []*db.Pull
1996
+
pullsToReopen = append(pullsToReopen, pull)
1997
+
1998
+
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
1999
+
if pull.IsStacked() {
2000
+
stack := r.Context().Value("stack").(db.Stack)
2001
+
subStack := stack.StrictlyAbove(pull)
2002
+
pullsToReopen = append(pullsToReopen, subStack...)
2003
+
}
2004
+
2005
+
for _, p := range pullsToReopen {
2006
+
// Close the pull in the database
2007
+
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2008
+
if err != nil {
2009
+
log.Println("failed to close pull", err)
2010
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2011
+
return
2012
+
}
1631
2013
}
1632
2014
1633
2015
// Commit the transaction
···
1640
2022
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1641
2023
return
1642
2024
}
2025
+
2026
+
func newStack(f *FullyResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2027
+
formatPatches, err := patchutil.ExtractPatches(patch)
2028
+
if err != nil {
2029
+
return nil, fmt.Errorf("Failed to extract patches: %v", err)
2030
+
}
2031
+
2032
+
// must have atleast 1 patch to begin with
2033
+
if len(formatPatches) == 0 {
2034
+
return nil, fmt.Errorf("No patches found in the generated format-patch.")
2035
+
}
2036
+
2037
+
// the stack is identified by a UUID
2038
+
var stack db.Stack
2039
+
parentChangeId := ""
2040
+
for _, fp := range formatPatches {
2041
+
// all patches must have a jj change-id
2042
+
changeId, err := fp.ChangeId()
2043
+
if err != nil {
2044
+
return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2045
+
}
2046
+
2047
+
title := fp.Title
2048
+
body := fp.Body
2049
+
rkey := appview.TID()
2050
+
2051
+
initialSubmission := db.PullSubmission{
2052
+
Patch: fp.Raw,
2053
+
SourceRev: fp.SHA,
2054
+
}
2055
+
pull := db.Pull{
2056
+
Title: title,
2057
+
Body: body,
2058
+
TargetBranch: targetBranch,
2059
+
OwnerDid: user.Did,
2060
+
RepoAt: f.RepoAt,
2061
+
Rkey: rkey,
2062
+
Submissions: []*db.PullSubmission{
2063
+
&initialSubmission,
2064
+
},
2065
+
PullSource: pullSource,
2066
+
Created: time.Now(),
2067
+
2068
+
StackId: stackId,
2069
+
ChangeId: changeId,
2070
+
ParentChangeId: parentChangeId,
2071
+
}
2072
+
2073
+
stack = append(stack, &pull)
2074
+
2075
+
parentChangeId = changeId
2076
+
}
2077
+
2078
+
return stack, nil
2079
+
}
+272
-159
appview/state/repo.go
+272
-159
appview/state/repo.go
···
18
18
19
19
"tangled.sh/tangled.sh/core/api/tangled"
20
20
"tangled.sh/tangled.sh/core/appview"
21
-
"tangled.sh/tangled.sh/core/appview/auth"
22
21
"tangled.sh/tangled.sh/core/appview/db"
22
+
"tangled.sh/tangled.sh/core/appview/oauth"
23
23
"tangled.sh/tangled.sh/core/appview/pages"
24
24
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
25
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
26
"tangled.sh/tangled.sh/core/appview/pagination"
27
+
"tangled.sh/tangled.sh/core/knotclient"
27
28
"tangled.sh/tangled.sh/core/types"
28
29
29
30
"github.com/bluesky-social/indigo/atproto/data"
···
32
33
securejoin "github.com/cyphar/filepath-securejoin"
33
34
"github.com/go-chi/chi/v5"
34
35
"github.com/go-git/go-git/v5/plumbing"
36
+
"github.com/posthog/posthog-go"
35
37
36
38
comatproto "github.com/bluesky-social/indigo/api/atproto"
37
39
lexutil "github.com/bluesky-social/indigo/lex/util"
···
45
47
return
46
48
}
47
49
48
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
50
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
49
51
if err != nil {
50
52
log.Printf("failed to create unsigned client for %s", f.Knot)
51
53
s.pages.Error503(w)
52
54
return
53
55
}
54
56
55
-
resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
57
+
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
56
58
if err != nil {
57
59
s.pages.Error503(w)
58
60
log.Println("failed to reach knotserver", err)
59
61
return
60
62
}
61
-
defer resp.Body.Close()
62
-
63
-
body, err := io.ReadAll(resp.Body)
64
-
if err != nil {
65
-
log.Printf("Error reading response body: %v", err)
66
-
return
67
-
}
68
-
69
-
var result types.RepoIndexResponse
70
-
err = json.Unmarshal(body, &result)
71
-
if err != nil {
72
-
log.Printf("Error unmarshalling response body: %v", err)
73
-
return
74
-
}
75
63
76
64
tagMap := make(map[string][]string)
77
65
for _, tag := range result.Tags {
···
98
86
return 1
99
87
}
100
88
if a.Commit != nil {
101
-
if a.Commit.Author.When.Before(b.Commit.Author.When) {
89
+
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
102
90
return 1
103
91
} else {
104
92
return -1
···
119
107
120
108
emails := uniqueEmails(commitsTrunc)
121
109
122
-
user := s.auth.GetUser(r)
110
+
user := s.oauth.GetUser(r)
111
+
repoInfo := f.RepoInfo(s, user)
112
+
113
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
114
+
if err != nil {
115
+
log.Printf("failed to get registration key for %s: %s", f.Knot, err)
116
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
117
+
}
118
+
119
+
signedClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
120
+
if err != nil {
121
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
122
+
return
123
+
}
124
+
125
+
var forkInfo *types.ForkInfo
126
+
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
127
+
forkInfo, err = getForkInfo(repoInfo, s, f, user, signedClient)
128
+
if err != nil {
129
+
log.Printf("Failed to fetch fork information: %v", err)
130
+
return
131
+
}
132
+
}
133
+
134
+
repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref)
135
+
if err != nil {
136
+
log.Printf("failed to compute language percentages: %s", err)
137
+
// non-fatal
138
+
}
139
+
123
140
s.pages.RepoIndexPage(w, pages.RepoIndexParams{
124
141
LoggedInUser: user,
125
-
RepoInfo: f.RepoInfo(s, user),
142
+
RepoInfo: repoInfo,
126
143
TagMap: tagMap,
127
-
RepoIndexResponse: result,
144
+
RepoIndexResponse: *result,
128
145
CommitsTrunc: commitsTrunc,
129
146
TagsTrunc: tagsTrunc,
147
+
ForkInfo: forkInfo,
130
148
BranchesTrunc: branchesTrunc,
131
149
EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
150
+
Languages: repoLanguages,
132
151
})
133
152
return
134
153
}
135
154
155
+
func getForkInfo(
156
+
repoInfo repoinfo.RepoInfo,
157
+
s *State,
158
+
f *FullyResolvedRepo,
159
+
user *oauth.User,
160
+
signedClient *knotclient.SignedClient,
161
+
) (*types.ForkInfo, error) {
162
+
if user == nil {
163
+
return nil, nil
164
+
}
165
+
166
+
forkInfo := types.ForkInfo{
167
+
IsFork: repoInfo.Source != nil,
168
+
Status: types.UpToDate,
169
+
}
170
+
171
+
if !forkInfo.IsFork {
172
+
forkInfo.IsFork = false
173
+
return &forkInfo, nil
174
+
}
175
+
176
+
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, s.config.Core.Dev)
177
+
if err != nil {
178
+
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
179
+
return nil, err
180
+
}
181
+
182
+
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
183
+
if err != nil {
184
+
log.Println("failed to reach knotserver", err)
185
+
return nil, err
186
+
}
187
+
188
+
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
189
+
return branch.Name == f.Ref
190
+
}) {
191
+
forkInfo.Status = types.MissingBranch
192
+
return &forkInfo, nil
193
+
}
194
+
195
+
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
196
+
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
197
+
log.Printf("failed to update tracking branch: %s", err)
198
+
return nil, err
199
+
}
200
+
201
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
202
+
203
+
var status types.AncestorCheckResponse
204
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
205
+
if err != nil {
206
+
log.Printf("failed to check if fork is ahead/behind: %s", err)
207
+
return nil, err
208
+
}
209
+
210
+
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
211
+
log.Printf("failed to decode fork status: %s", err)
212
+
return nil, err
213
+
}
214
+
215
+
forkInfo.Status = status.Status
216
+
return &forkInfo, nil
217
+
}
218
+
136
219
func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
137
220
f, err := s.fullyResolvedRepo(r)
138
221
if err != nil {
···
150
233
151
234
ref := chi.URLParam(r, "ref")
152
235
153
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
236
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
154
237
if err != nil {
155
238
log.Println("failed to create unsigned client", err)
156
239
return
157
240
}
158
241
159
-
resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
242
+
repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
160
243
if err != nil {
161
244
log.Println("failed to reach knotserver", err)
162
245
return
163
246
}
164
247
165
-
body, err := io.ReadAll(resp.Body)
166
-
if err != nil {
167
-
log.Printf("error reading response body: %v", err)
168
-
return
169
-
}
170
-
171
-
var repolog types.RepoLogResponse
172
-
err = json.Unmarshal(body, &repolog)
173
-
if err != nil {
174
-
log.Println("failed to parse json response", err)
175
-
return
176
-
}
177
-
178
248
result, err := us.Tags(f.OwnerDid(), f.RepoName)
179
249
if err != nil {
180
250
log.Println("failed to reach knotserver", err)
···
190
260
tagMap[hash] = append(tagMap[hash], tag.Name)
191
261
}
192
262
193
-
user := s.auth.GetUser(r)
263
+
user := s.oauth.GetUser(r)
194
264
s.pages.RepoLog(w, pages.RepoLogParams{
195
265
LoggedInUser: user,
196
266
TagMap: tagMap,
197
267
RepoInfo: f.RepoInfo(s, user),
198
-
RepoLogResponse: repolog,
268
+
RepoLogResponse: *repolog,
199
269
EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
200
270
})
201
271
return
···
209
279
return
210
280
}
211
281
212
-
user := s.auth.GetUser(r)
282
+
user := s.oauth.GetUser(r)
213
283
s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
214
284
RepoInfo: f.RepoInfo(s, user),
215
285
})
···
232
302
return
233
303
}
234
304
235
-
user := s.auth.GetUser(r)
305
+
user := s.oauth.GetUser(r)
236
306
237
307
switch r.Method {
238
308
case http.MethodGet:
···
241
311
})
242
312
return
243
313
case http.MethodPut:
244
-
user := s.auth.GetUser(r)
314
+
user := s.oauth.GetUser(r)
245
315
newDescription := r.FormValue("description")
246
-
client, _ := s.auth.AuthorizedClient(r)
316
+
client, err := s.oauth.AuthorizedClient(r)
317
+
if err != nil {
318
+
log.Println("failed to get client")
319
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
320
+
return
321
+
}
247
322
248
323
// optimistic update
249
324
err = db.UpdateDescription(s.db, string(repoAt), newDescription)
···
256
331
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
257
332
//
258
333
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
259
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
334
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
260
335
if err != nil {
261
336
// failed to get record
262
337
s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
263
338
return
264
339
}
265
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
340
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
266
341
Collection: tangled.RepoNSID,
267
342
Repo: user.Did,
268
343
Rkey: rkey,
···
303
378
}
304
379
ref := chi.URLParam(r, "ref")
305
380
protocol := "http"
306
-
if !s.config.Dev {
381
+
if !s.config.Core.Dev {
307
382
protocol = "https"
308
383
}
309
384
···
331
406
return
332
407
}
333
408
334
-
user := s.auth.GetUser(r)
409
+
user := s.oauth.GetUser(r)
335
410
s.pages.RepoCommit(w, pages.RepoCommitParams{
336
411
LoggedInUser: user,
337
412
RepoInfo: f.RepoInfo(s, user),
···
351
426
ref := chi.URLParam(r, "ref")
352
427
treePath := chi.URLParam(r, "*")
353
428
protocol := "http"
354
-
if !s.config.Dev {
429
+
if !s.config.Core.Dev {
355
430
protocol = "https"
356
431
}
357
432
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
···
380
455
return
381
456
}
382
457
383
-
user := s.auth.GetUser(r)
458
+
user := s.oauth.GetUser(r)
384
459
385
460
var breadcrumbs [][]string
386
461
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
···
411
486
return
412
487
}
413
488
414
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
489
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
415
490
if err != nil {
416
491
log.Println("failed to create unsigned client", err)
417
492
return
···
423
498
return
424
499
}
425
500
426
-
artifacts, err := db.GetArtifact(s.db, db.Filter("repo_at", f.RepoAt))
501
+
artifacts, err := db.GetArtifact(s.db, db.FilterEq("repo_at", f.RepoAt))
427
502
if err != nil {
428
503
log.Println("failed grab artifacts", err)
429
504
return
···
451
526
}
452
527
}
453
528
454
-
user := s.auth.GetUser(r)
529
+
user := s.oauth.GetUser(r)
455
530
s.pages.RepoTags(w, pages.RepoTagsParams{
456
531
LoggedInUser: user,
457
532
RepoInfo: f.RepoInfo(s, user),
···
469
544
return
470
545
}
471
546
472
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
547
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
473
548
if err != nil {
474
549
log.Println("failed to create unsigned client", err)
475
550
return
476
551
}
477
552
478
-
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
553
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
479
554
if err != nil {
480
555
log.Println("failed to reach knotserver", err)
481
556
return
482
557
}
483
558
484
-
body, err := io.ReadAll(resp.Body)
485
-
if err != nil {
486
-
log.Printf("Error reading response body: %v", err)
487
-
return
488
-
}
489
-
490
-
var result types.RepoBranchesResponse
491
-
err = json.Unmarshal(body, &result)
492
-
if err != nil {
493
-
log.Println("failed to parse response:", err)
494
-
return
495
-
}
496
-
497
559
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
498
560
if a.IsDefault {
499
561
return -1
···
502
564
return 1
503
565
}
504
566
if a.Commit != nil {
505
-
if a.Commit.Author.When.Before(b.Commit.Author.When) {
567
+
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
506
568
return 1
507
569
} else {
508
570
return -1
···
511
573
return strings.Compare(a.Name, b.Name) * -1
512
574
})
513
575
514
-
user := s.auth.GetUser(r)
576
+
user := s.oauth.GetUser(r)
515
577
s.pages.RepoBranches(w, pages.RepoBranchesParams{
516
578
LoggedInUser: user,
517
579
RepoInfo: f.RepoInfo(s, user),
518
-
RepoBranchesResponse: result,
580
+
RepoBranchesResponse: *result,
519
581
})
520
582
return
521
583
}
···
530
592
ref := chi.URLParam(r, "ref")
531
593
filePath := chi.URLParam(r, "*")
532
594
protocol := "http"
533
-
if !s.config.Dev {
595
+
if !s.config.Core.Dev {
534
596
protocol = "https"
535
597
}
536
598
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
···
568
630
showRendered = r.URL.Query().Get("code") != "true"
569
631
}
570
632
571
-
user := s.auth.GetUser(r)
633
+
user := s.oauth.GetUser(r)
572
634
s.pages.RepoBlob(w, pages.RepoBlobParams{
573
635
LoggedInUser: user,
574
636
RepoInfo: f.RepoInfo(s, user),
···
591
653
filePath := chi.URLParam(r, "*")
592
654
593
655
protocol := "http"
594
-
if !s.config.Dev {
656
+
if !s.config.Core.Dev {
595
657
protocol = "https"
596
658
}
597
659
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
···
652
714
return
653
715
}
654
716
655
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
717
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
656
718
if err != nil {
657
719
log.Println("failed to create client to ", f.Knot)
658
720
return
···
714
776
}
715
777
716
778
func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
717
-
user := s.auth.GetUser(r)
779
+
user := s.oauth.GetUser(r)
718
780
719
781
f, err := s.fullyResolvedRepo(r)
720
782
if err != nil {
···
723
785
}
724
786
725
787
// remove record from pds
726
-
xrpcClient, _ := s.auth.AuthorizedClient(r)
788
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
789
+
if err != nil {
790
+
log.Println("failed to get authorized client", err)
791
+
return
792
+
}
727
793
repoRkey := f.RepoAt.RecordKey().String()
728
-
_, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{
794
+
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
729
795
Collection: tangled.RepoNSID,
730
796
Repo: user.Did,
731
797
Rkey: repoRkey,
···
743
809
return
744
810
}
745
811
746
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
812
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
747
813
if err != nil {
748
814
log.Println("failed to create client to ", f.Knot)
749
815
return
···
838
904
return
839
905
}
840
906
841
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
907
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
842
908
if err != nil {
843
909
log.Println("failed to create client to ", f.Knot)
844
910
return
···
868
934
switch r.Method {
869
935
case http.MethodGet:
870
936
// for now, this is just pubkeys
871
-
user := s.auth.GetUser(r)
937
+
user := s.oauth.GetUser(r)
872
938
repoCollaborators, err := f.Collaborators(r.Context(), s)
873
939
if err != nil {
874
940
log.Println("failed to get collaborators", err)
···
882
948
}
883
949
}
884
950
885
-
var branchNames []string
886
-
var defaultBranch string
887
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
951
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
888
952
if err != nil {
889
953
log.Println("failed to create unsigned client", err)
890
-
} else {
891
-
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
892
-
if err != nil {
893
-
log.Println("failed to reach knotserver", err)
894
-
} else {
895
-
defer resp.Body.Close()
896
-
897
-
body, err := io.ReadAll(resp.Body)
898
-
if err != nil {
899
-
log.Printf("Error reading response body: %v", err)
900
-
} else {
901
-
var result types.RepoBranchesResponse
902
-
err = json.Unmarshal(body, &result)
903
-
if err != nil {
904
-
log.Println("failed to parse response:", err)
905
-
} else {
906
-
for _, branch := range result.Branches {
907
-
branchNames = append(branchNames, branch.Name)
908
-
}
909
-
}
910
-
}
911
-
}
954
+
return
955
+
}
912
956
913
-
defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName)
914
-
if err != nil {
915
-
log.Println("failed to reach knotserver", err)
916
-
} else {
917
-
defaultBranch = defaultBranchResp.Branch
918
-
}
957
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
958
+
if err != nil {
959
+
log.Println("failed to reach knotserver", err)
960
+
return
919
961
}
962
+
920
963
s.pages.RepoSettings(w, pages.RepoSettingsParams{
921
964
LoggedInUser: user,
922
965
RepoInfo: f.RepoInfo(s, user),
923
966
Collaborators: repoCollaborators,
924
967
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
925
-
Branches: branchNames,
926
-
DefaultBranch: defaultBranch,
968
+
Branches: result.Branches,
927
969
})
928
970
}
929
971
}
···
936
978
Description string
937
979
CreatedAt string
938
980
Ref string
981
+
CurrentDir string
939
982
}
940
983
941
984
func (f *FullyResolvedRepo) OwnerDid() string {
···
1008
1051
return collaborators, nil
1009
1052
}
1010
1053
1011
-
func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) repoinfo.RepoInfo {
1054
+
func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo {
1012
1055
isStarred := false
1013
1056
if u != nil {
1014
1057
isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
···
1051
1094
1052
1095
knot := f.Knot
1053
1096
var disableFork bool
1054
-
us, err := NewUnsignedClient(knot, s.config.Dev)
1097
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
1055
1098
if err != nil {
1056
1099
log.Printf("failed to create unsigned client for %s: %v", knot, err)
1057
1100
} else {
1058
-
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1101
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1059
1102
if err != nil {
1060
1103
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1061
-
} else {
1062
-
defer resp.Body.Close()
1063
-
body, err := io.ReadAll(resp.Body)
1064
-
if err != nil {
1065
-
log.Printf("error reading branch response body: %v", err)
1066
-
} else {
1067
-
var branchesResp types.RepoBranchesResponse
1068
-
if err := json.Unmarshal(body, &branchesResp); err != nil {
1069
-
log.Printf("error parsing branch response: %v", err)
1070
-
} else {
1071
-
disableFork = false
1072
-
}
1104
+
}
1073
1105
1074
-
if len(branchesResp.Branches) == 0 {
1075
-
disableFork = true
1076
-
}
1077
-
}
1106
+
if len(result.Branches) == 0 {
1107
+
disableFork = true
1078
1108
}
1079
1109
}
1080
1110
···
1094
1124
PullCount: pullCount,
1095
1125
},
1096
1126
DisableFork: disableFork,
1127
+
CurrentDir: f.CurrentDir,
1097
1128
}
1098
1129
1099
1130
if sourceRepo != nil {
···
1105
1136
}
1106
1137
1107
1138
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1108
-
user := s.auth.GetUser(r)
1139
+
user := s.oauth.GetUser(r)
1109
1140
f, err := s.fullyResolvedRepo(r)
1110
1141
if err != nil {
1111
1142
log.Println("failed to get repo and knot", err)
···
1159
1190
}
1160
1191
1161
1192
func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1162
-
user := s.auth.GetUser(r)
1193
+
user := s.oauth.GetUser(r)
1163
1194
f, err := s.fullyResolvedRepo(r)
1164
1195
if err != nil {
1165
1196
log.Println("failed to get repo and knot", err)
···
1195
1226
1196
1227
closed := tangled.RepoIssueStateClosed
1197
1228
1198
-
client, _ := s.auth.AuthorizedClient(r)
1199
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1229
+
client, err := s.oauth.AuthorizedClient(r)
1230
+
if err != nil {
1231
+
log.Println("failed to get authorized client", err)
1232
+
return
1233
+
}
1234
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1200
1235
Collection: tangled.RepoIssueStateNSID,
1201
1236
Repo: user.Did,
1202
1237
Rkey: appview.TID(),
···
1214
1249
return
1215
1250
}
1216
1251
1217
-
err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1252
+
err = db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1218
1253
if err != nil {
1219
1254
log.Println("failed to close issue", err)
1220
1255
s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
1231
1266
}
1232
1267
1233
1268
func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1234
-
user := s.auth.GetUser(r)
1269
+
user := s.oauth.GetUser(r)
1235
1270
f, err := s.fullyResolvedRepo(r)
1236
1271
if err != nil {
1237
1272
log.Println("failed to get repo and knot", err)
···
1279
1314
}
1280
1315
1281
1316
func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1282
-
user := s.auth.GetUser(r)
1317
+
user := s.oauth.GetUser(r)
1283
1318
f, err := s.fullyResolvedRepo(r)
1284
1319
if err != nil {
1285
1320
log.Println("failed to get repo and knot", err)
···
1330
1365
}
1331
1366
1332
1367
atUri := f.RepoAt.String()
1333
-
client, _ := s.auth.AuthorizedClient(r)
1334
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1368
+
client, err := s.oauth.AuthorizedClient(r)
1369
+
if err != nil {
1370
+
log.Println("failed to get authorized client", err)
1371
+
s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1372
+
return
1373
+
}
1374
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1335
1375
Collection: tangled.RepoIssueCommentNSID,
1336
1376
Repo: user.Did,
1337
1377
Rkey: rkey,
···
1358
1398
}
1359
1399
1360
1400
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1361
-
user := s.auth.GetUser(r)
1401
+
user := s.oauth.GetUser(r)
1362
1402
f, err := s.fullyResolvedRepo(r)
1363
1403
if err != nil {
1364
1404
log.Println("failed to get repo and knot", err)
···
1417
1457
}
1418
1458
1419
1459
func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1420
-
user := s.auth.GetUser(r)
1460
+
user := s.oauth.GetUser(r)
1421
1461
f, err := s.fullyResolvedRepo(r)
1422
1462
if err != nil {
1423
1463
log.Println("failed to get repo and knot", err)
···
1469
1509
case http.MethodPost:
1470
1510
// extract form value
1471
1511
newBody := r.FormValue("body")
1472
-
client, _ := s.auth.AuthorizedClient(r)
1512
+
client, err := s.oauth.AuthorizedClient(r)
1513
+
if err != nil {
1514
+
log.Println("failed to get authorized client", err)
1515
+
s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1516
+
return
1517
+
}
1473
1518
rkey := comment.Rkey
1474
1519
1475
1520
// optimistic update
···
1484
1529
// rkey is optional, it was introduced later
1485
1530
if comment.Rkey != "" {
1486
1531
// update the record on pds
1487
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1532
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1488
1533
if err != nil {
1489
1534
// failed to get record
1490
1535
log.Println(err, rkey)
···
1499
1544
createdAt := record["createdAt"].(string)
1500
1545
commentIdInt64 := int64(commentIdInt)
1501
1546
1502
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1547
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1503
1548
Collection: tangled.RepoIssueCommentNSID,
1504
1549
Repo: user.Did,
1505
1550
Rkey: rkey,
···
1542
1587
}
1543
1588
1544
1589
func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1545
-
user := s.auth.GetUser(r)
1590
+
user := s.oauth.GetUser(r)
1546
1591
f, err := s.fullyResolvedRepo(r)
1547
1592
if err != nil {
1548
1593
log.Println("failed to get repo and knot", err)
···
1599
1644
1600
1645
// delete from pds
1601
1646
if comment.Rkey != "" {
1602
-
client, _ := s.auth.AuthorizedClient(r)
1603
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1647
+
client, err := s.oauth.AuthorizedClient(r)
1648
+
if err != nil {
1649
+
log.Println("failed to get authorized client", err)
1650
+
s.pages.Notice(w, "issue-comment", "Failed to delete comment.")
1651
+
return
1652
+
}
1653
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1604
1654
Collection: tangled.GraphFollowNSID,
1605
1655
Repo: user.Did,
1606
1656
Rkey: comment.Rkey,
···
1647
1697
page = pagination.FirstPage()
1648
1698
}
1649
1699
1650
-
user := s.auth.GetUser(r)
1700
+
user := s.oauth.GetUser(r)
1651
1701
f, err := s.fullyResolvedRepo(r)
1652
1702
if err != nil {
1653
1703
log.Println("failed to get repo and knot", err)
···
1676
1726
}
1677
1727
1678
1728
s.pages.RepoIssues(w, pages.RepoIssuesParams{
1679
-
LoggedInUser: s.auth.GetUser(r),
1729
+
LoggedInUser: s.oauth.GetUser(r),
1680
1730
RepoInfo: f.RepoInfo(s, user),
1681
1731
Issues: issues,
1682
1732
DidHandleMap: didHandleMap,
···
1687
1737
}
1688
1738
1689
1739
func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1690
-
user := s.auth.GetUser(r)
1740
+
user := s.oauth.GetUser(r)
1691
1741
1692
1742
f, err := s.fullyResolvedRepo(r)
1693
1743
if err != nil {
···
1735
1785
return
1736
1786
}
1737
1787
1738
-
client, _ := s.auth.AuthorizedClient(r)
1788
+
client, err := s.oauth.AuthorizedClient(r)
1789
+
if err != nil {
1790
+
log.Println("failed to get authorized client", err)
1791
+
s.pages.Notice(w, "issues", "Failed to create issue.")
1792
+
return
1793
+
}
1739
1794
atUri := f.RepoAt.String()
1740
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1795
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1741
1796
Collection: tangled.RepoIssueNSID,
1742
1797
Repo: user.Did,
1743
1798
Rkey: appview.TID(),
···
1764
1819
return
1765
1820
}
1766
1821
1822
+
if !s.config.Core.Dev {
1823
+
err = s.posthog.Enqueue(posthog.Capture{
1824
+
DistinctId: user.Did,
1825
+
Event: "new_issue",
1826
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
1827
+
})
1828
+
if err != nil {
1829
+
log.Println("failed to enqueue posthog event:", err)
1830
+
}
1831
+
}
1832
+
1767
1833
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1768
1834
return
1769
1835
}
1770
1836
}
1771
1837
1838
+
func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1839
+
user := s.oauth.GetUser(r)
1840
+
f, err := s.fullyResolvedRepo(r)
1841
+
if err != nil {
1842
+
log.Printf("failed to resolve source repo: %v", err)
1843
+
return
1844
+
}
1845
+
1846
+
switch r.Method {
1847
+
case http.MethodPost:
1848
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
1849
+
if err != nil {
1850
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1851
+
return
1852
+
}
1853
+
1854
+
client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1855
+
if err != nil {
1856
+
s.pages.Notice(w, "repo", "Failed to reach knot server.")
1857
+
return
1858
+
}
1859
+
1860
+
var uri string
1861
+
if s.config.Core.Dev {
1862
+
uri = "http"
1863
+
} else {
1864
+
uri = "https"
1865
+
}
1866
+
forkName := fmt.Sprintf("%s", f.RepoName)
1867
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1868
+
1869
+
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1870
+
if err != nil {
1871
+
s.pages.Notice(w, "repo", "Failed to sync repository fork.")
1872
+
return
1873
+
}
1874
+
1875
+
s.pages.HxRefresh(w)
1876
+
return
1877
+
}
1878
+
}
1879
+
1772
1880
func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1773
-
user := s.auth.GetUser(r)
1881
+
user := s.oauth.GetUser(r)
1774
1882
f, err := s.fullyResolvedRepo(r)
1775
1883
if err != nil {
1776
1884
log.Printf("failed to resolve source repo: %v", err)
···
1779
1887
1780
1888
switch r.Method {
1781
1889
case http.MethodGet:
1782
-
user := s.auth.GetUser(r)
1890
+
user := s.oauth.GetUser(r)
1783
1891
knots, err := s.enforcer.GetDomainsForUser(user.Did)
1784
1892
if err != nil {
1785
1893
s.pages.Notice(w, "repo", "Invalid user account.")
···
1829
1937
return
1830
1938
}
1831
1939
1832
-
client, err := NewSignedClient(knot, secret, s.config.Dev)
1940
+
client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev)
1833
1941
if err != nil {
1834
1942
s.pages.Notice(w, "repo", "Failed to reach knot server.")
1835
1943
return
1836
1944
}
1837
1945
1838
1946
var uri string
1839
-
if s.config.Dev {
1947
+
if s.config.Core.Dev {
1840
1948
uri = "http"
1841
1949
} else {
1842
1950
uri = "https"
···
1883
1991
// continue
1884
1992
}
1885
1993
1886
-
xrpcClient, _ := s.auth.AuthorizedClient(r)
1994
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
1995
+
if err != nil {
1996
+
log.Println("failed to get authorized client", err)
1997
+
s.pages.Notice(w, "repo", "Failed to create repository.")
1998
+
return
1999
+
}
1887
2000
1888
2001
createdAt := time.Now().Format(time.RFC3339)
1889
-
atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
2002
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1890
2003
Collection: tangled.RepoNSID,
1891
2004
Repo: user.Did,
1892
2005
Rkey: rkey,
+35
-3
appview/state/repo_util.go
+35
-3
appview/state/repo_util.go
···
7
7
"log"
8
8
"math/big"
9
9
"net/http"
10
+
"net/url"
11
+
"path"
12
+
"strings"
10
13
11
14
"github.com/bluesky-social/indigo/atproto/identity"
12
15
"github.com/bluesky-social/indigo/atproto/syntax"
13
16
"github.com/go-chi/chi/v5"
14
17
"github.com/go-git/go-git/v5/plumbing/object"
15
-
"tangled.sh/tangled.sh/core/appview/auth"
16
18
"tangled.sh/tangled.sh/core/appview/db"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
17
20
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
21
+
"tangled.sh/tangled.sh/core/knotclient"
18
22
)
19
23
20
24
func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
···
45
49
ref := chi.URLParam(r, "ref")
46
50
47
51
if ref == "" {
48
-
us, err := NewUnsignedClient(knot, s.config.Dev)
52
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
49
53
if err != nil {
50
54
return nil, err
51
55
}
···
57
61
58
62
ref = defaultBranch.Branch
59
63
}
64
+
65
+
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
60
66
61
67
// pass through values from the middleware
62
68
description, ok := r.Context().Value("repoDescription").(string)
···
70
76
Description: description,
71
77
CreatedAt: addedAt,
72
78
Ref: ref,
79
+
CurrentDir: currentDir,
73
80
}, nil
74
81
}
75
82
76
-
func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo {
83
+
func RolesInRepo(s *State, u *oauth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo {
77
84
if u != nil {
78
85
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
79
86
return repoinfo.RolesInRepo{r}
80
87
} else {
81
88
return repoinfo.RolesInRepo{}
82
89
}
90
+
}
91
+
92
+
// extractPathAfterRef gets the actual repository path
93
+
// after the ref. for example:
94
+
//
95
+
// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/
96
+
func extractPathAfterRef(fullPath, ref string) string {
97
+
fullPath = strings.TrimPrefix(fullPath, "/")
98
+
99
+
ref = url.PathEscape(ref)
100
+
101
+
prefixes := []string{
102
+
fmt.Sprintf("blob/%s/", ref),
103
+
fmt.Sprintf("tree/%s/", ref),
104
+
fmt.Sprintf("raw/%s/", ref),
105
+
}
106
+
107
+
for _, prefix := range prefixes {
108
+
idx := strings.Index(fullPath, prefix)
109
+
if idx != -1 {
110
+
return fullPath[idx+len(prefix):]
111
+
}
112
+
}
113
+
114
+
return ""
83
115
}
84
116
85
117
func uniqueEmails(commits []*object.Commit) []string {
+47
-20
appview/state/router.go
+47
-20
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
+
"github.com/gorilla/sessions"
8
9
"tangled.sh/tangled.sh/core/appview/middleware"
10
+
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
9
11
"tangled.sh/tangled.sh/core/appview/settings"
10
12
"tangled.sh/tangled.sh/core/appview/state/userutil"
11
13
)
···
53
55
r.Use(StripLeadingAt)
54
56
55
57
r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
56
-
r.Get("/", s.ProfilePage)
58
+
r.Get("/", s.Profile)
59
+
57
60
r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) {
61
+
r.Use(GoImport(s))
62
+
58
63
r.Get("/", s.RepoIndex)
59
64
r.Get("/commits/{ref}", s.RepoLog)
60
65
r.Route("/tree/{ref}", func(r chi.Router) {
···
66
71
r.Route("/tags", func(r chi.Router) {
67
72
r.Get("/", s.RepoTags)
68
73
r.Route("/{tag}", func(r chi.Router) {
69
-
r.Use(middleware.AuthMiddleware(s.auth))
74
+
r.Use(middleware.AuthMiddleware(s.oauth))
70
75
// require auth to download for now
71
76
r.Get("/download/{file}", s.DownloadArtifact)
72
77
···
89
94
r.Get("/{issue}", s.RepoSingleIssue)
90
95
91
96
r.Group(func(r chi.Router) {
92
-
r.Use(middleware.AuthMiddleware(s.auth))
97
+
r.Use(middleware.AuthMiddleware(s.oauth))
93
98
r.Get("/new", s.NewIssue)
94
99
r.Post("/new", s.NewIssue)
95
100
r.Post("/{issue}/comment", s.NewIssueComment)
···
105
110
})
106
111
107
112
r.Route("/fork", func(r chi.Router) {
108
-
r.Use(middleware.AuthMiddleware(s.auth))
113
+
r.Use(middleware.AuthMiddleware(s.oauth))
109
114
r.Get("/", s.ForkRepo)
110
115
r.Post("/", s.ForkRepo)
116
+
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/sync", func(r chi.Router) {
117
+
r.Post("/", s.SyncRepoFork)
118
+
})
111
119
})
112
120
113
121
r.Route("/pulls", func(r chi.Router) {
114
122
r.Get("/", s.RepoPulls)
115
-
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
123
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) {
116
124
r.Get("/", s.NewPull)
117
125
r.Get("/patch-upload", s.PatchUploadFragment)
118
126
r.Post("/validate-patch", s.ValidatePatch)
···
130
138
r.Get("/", s.RepoPullPatch)
131
139
r.Get("/interdiff", s.RepoPullInterdiff)
132
140
r.Get("/actions", s.PullActions)
133
-
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
141
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) {
134
142
r.Get("/", s.PullComment)
135
143
r.Post("/", s.PullComment)
136
144
})
···
141
149
})
142
150
143
151
r.Group(func(r chi.Router) {
144
-
r.Use(middleware.AuthMiddleware(s.auth))
152
+
r.Use(middleware.AuthMiddleware(s.oauth))
145
153
r.Route("/resubmit", func(r chi.Router) {
146
154
r.Get("/", s.ResubmitPull)
147
155
r.Post("/", s.ResubmitPull)
···
161
169
// These routes get proxied to the knot
162
170
r.Get("/info/refs", s.InfoRefs)
163
171
r.Post("/git-upload-pack", s.UploadPack)
172
+
r.Post("/git-receive-pack", s.ReceivePack)
164
173
165
174
// settings routes, needs auth
166
175
r.Group(func(r chi.Router) {
167
-
r.Use(middleware.AuthMiddleware(s.auth))
176
+
r.Use(middleware.AuthMiddleware(s.oauth))
168
177
// repo description can only be edited by owner
169
178
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
170
179
r.Put("/", s.RepoDescription)
···
195
204
196
205
r.Get("/", s.Timeline)
197
206
198
-
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
199
-
200
-
r.Route("/login", func(r chi.Router) {
201
-
r.Get("/", s.Login)
202
-
r.Post("/", s.Login)
203
-
})
207
+
r.With(middleware.AuthMiddleware(s.oauth)).Post("/logout", s.Logout)
204
208
205
209
r.Route("/knots", func(r chi.Router) {
206
-
r.Use(middleware.AuthMiddleware(s.auth))
210
+
r.Use(middleware.AuthMiddleware(s.oauth))
207
211
r.Get("/", s.Knots)
208
212
r.Post("/key", s.RegistrationKey)
209
213
···
221
225
222
226
r.Route("/repo", func(r chi.Router) {
223
227
r.Route("/new", func(r chi.Router) {
224
-
r.Use(middleware.AuthMiddleware(s.auth))
228
+
r.Use(middleware.AuthMiddleware(s.oauth))
225
229
r.Get("/", s.NewRepo)
226
230
r.Post("/", s.NewRepo)
227
231
})
228
232
// r.Post("/import", s.ImportRepo)
229
233
})
230
234
231
-
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
235
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
232
236
r.Post("/", s.Follow)
233
237
r.Delete("/", s.Follow)
234
238
})
235
239
236
-
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
240
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
237
241
r.Post("/", s.Star)
238
242
r.Delete("/", s.Star)
239
243
})
240
244
245
+
r.Route("/profile", func(r chi.Router) {
246
+
r.Use(middleware.AuthMiddleware(s.oauth))
247
+
r.Get("/edit-bio", s.EditBioFragment)
248
+
r.Get("/edit-pins", s.EditPinsFragment)
249
+
r.Post("/bio", s.UpdateProfileBio)
250
+
r.Post("/pins", s.UpdateProfilePins)
251
+
})
252
+
241
253
r.Mount("/settings", s.SettingsRouter())
242
-
254
+
r.Mount("/", s.OAuthRouter())
243
255
r.Get("/keys/{user}", s.Keys)
244
256
245
257
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
···
248
260
return r
249
261
}
250
262
263
+
func (s *State) OAuthRouter() http.Handler {
264
+
oauth := &oauthhandler.OAuthHandler{
265
+
Config: s.config,
266
+
Pages: s.pages,
267
+
Resolver: s.resolver,
268
+
Db: s.db,
269
+
Store: sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)),
270
+
OAuth: s.oauth,
271
+
Enforcer: s.enforcer,
272
+
Posthog: s.posthog,
273
+
}
274
+
275
+
return oauth.Router()
276
+
}
277
+
251
278
func (s *State) SettingsRouter() http.Handler {
252
279
settings := &settings.Settings{
253
280
Db: s.db,
254
-
Auth: s.auth,
281
+
OAuth: s.oauth,
255
282
Pages: s.pages,
256
283
Config: s.config,
257
284
}
-489
appview/state/signer.go
-489
appview/state/signer.go
···
1
-
package state
2
-
3
-
import (
4
-
"bytes"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
8
-
"encoding/json"
9
-
"fmt"
10
-
"io"
11
-
"log"
12
-
"net/http"
13
-
"net/url"
14
-
"strconv"
15
-
"time"
16
-
17
-
"tangled.sh/tangled.sh/core/types"
18
-
)
19
-
20
-
type SignerTransport struct {
21
-
Secret string
22
-
}
23
-
24
-
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
25
-
timestamp := time.Now().Format(time.RFC3339)
26
-
mac := hmac.New(sha256.New, []byte(s.Secret))
27
-
message := req.Method + req.URL.Path + timestamp
28
-
mac.Write([]byte(message))
29
-
signature := hex.EncodeToString(mac.Sum(nil))
30
-
req.Header.Set("X-Signature", signature)
31
-
req.Header.Set("X-Timestamp", timestamp)
32
-
return http.DefaultTransport.RoundTrip(req)
33
-
}
34
-
35
-
type SignedClient struct {
36
-
Secret string
37
-
Url *url.URL
38
-
client *http.Client
39
-
}
40
-
41
-
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
42
-
client := &http.Client{
43
-
Timeout: 5 * time.Second,
44
-
Transport: SignerTransport{
45
-
Secret: secret,
46
-
},
47
-
}
48
-
49
-
scheme := "https"
50
-
if dev {
51
-
scheme = "http"
52
-
}
53
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
54
-
if err != nil {
55
-
return nil, err
56
-
}
57
-
58
-
signedClient := &SignedClient{
59
-
Secret: secret,
60
-
client: client,
61
-
Url: url,
62
-
}
63
-
64
-
return signedClient, nil
65
-
}
66
-
67
-
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
68
-
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
69
-
}
70
-
71
-
func (s *SignedClient) Init(did string) (*http.Response, error) {
72
-
const (
73
-
Method = "POST"
74
-
Endpoint = "/init"
75
-
)
76
-
77
-
body, _ := json.Marshal(map[string]any{
78
-
"did": did,
79
-
})
80
-
81
-
req, err := s.newRequest(Method, Endpoint, body)
82
-
if err != nil {
83
-
return nil, err
84
-
}
85
-
86
-
return s.client.Do(req)
87
-
}
88
-
89
-
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
90
-
const (
91
-
Method = "PUT"
92
-
Endpoint = "/repo/new"
93
-
)
94
-
95
-
body, _ := json.Marshal(map[string]any{
96
-
"did": did,
97
-
"name": repoName,
98
-
"default_branch": defaultBranch,
99
-
})
100
-
101
-
req, err := s.newRequest(Method, Endpoint, body)
102
-
if err != nil {
103
-
return nil, err
104
-
}
105
-
106
-
return s.client.Do(req)
107
-
}
108
-
109
-
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
110
-
const (
111
-
Method = "POST"
112
-
Endpoint = "/repo/fork"
113
-
)
114
-
115
-
body, _ := json.Marshal(map[string]any{
116
-
"did": ownerDid,
117
-
"source": source,
118
-
"name": name,
119
-
})
120
-
121
-
req, err := s.newRequest(Method, Endpoint, body)
122
-
if err != nil {
123
-
return nil, err
124
-
}
125
-
126
-
return s.client.Do(req)
127
-
}
128
-
129
-
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
130
-
const (
131
-
Method = "DELETE"
132
-
Endpoint = "/repo"
133
-
)
134
-
135
-
body, _ := json.Marshal(map[string]any{
136
-
"did": did,
137
-
"name": repoName,
138
-
})
139
-
140
-
req, err := s.newRequest(Method, Endpoint, body)
141
-
if err != nil {
142
-
return nil, err
143
-
}
144
-
145
-
return s.client.Do(req)
146
-
}
147
-
148
-
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
149
-
const (
150
-
Method = "PUT"
151
-
Endpoint = "/member/add"
152
-
)
153
-
154
-
body, _ := json.Marshal(map[string]any{
155
-
"did": did,
156
-
})
157
-
158
-
req, err := s.newRequest(Method, Endpoint, body)
159
-
if err != nil {
160
-
return nil, err
161
-
}
162
-
163
-
return s.client.Do(req)
164
-
}
165
-
166
-
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
167
-
const (
168
-
Method = "PUT"
169
-
)
170
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
171
-
172
-
body, _ := json.Marshal(map[string]any{
173
-
"branch": branch,
174
-
})
175
-
176
-
req, err := s.newRequest(Method, endpoint, body)
177
-
if err != nil {
178
-
return nil, err
179
-
}
180
-
181
-
return s.client.Do(req)
182
-
}
183
-
184
-
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
185
-
const (
186
-
Method = "POST"
187
-
)
188
-
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
189
-
190
-
body, _ := json.Marshal(map[string]any{
191
-
"did": memberDid,
192
-
})
193
-
194
-
req, err := s.newRequest(Method, endpoint, body)
195
-
if err != nil {
196
-
return nil, err
197
-
}
198
-
199
-
return s.client.Do(req)
200
-
}
201
-
202
-
func (s *SignedClient) Merge(
203
-
patch []byte,
204
-
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
205
-
) (*http.Response, error) {
206
-
const (
207
-
Method = "POST"
208
-
)
209
-
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
210
-
211
-
mr := types.MergeRequest{
212
-
Branch: branch,
213
-
CommitMessage: commitMessage,
214
-
CommitBody: commitBody,
215
-
AuthorName: authorName,
216
-
AuthorEmail: authorEmail,
217
-
Patch: string(patch),
218
-
}
219
-
220
-
body, _ := json.Marshal(mr)
221
-
222
-
req, err := s.newRequest(Method, endpoint, body)
223
-
if err != nil {
224
-
return nil, err
225
-
}
226
-
227
-
return s.client.Do(req)
228
-
}
229
-
230
-
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
231
-
const (
232
-
Method = "POST"
233
-
)
234
-
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
235
-
236
-
body, _ := json.Marshal(map[string]any{
237
-
"patch": string(patch),
238
-
"branch": branch,
239
-
})
240
-
241
-
req, err := s.newRequest(Method, endpoint, body)
242
-
if err != nil {
243
-
return nil, err
244
-
}
245
-
246
-
return s.client.Do(req)
247
-
}
248
-
249
-
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
250
-
const (
251
-
Method = "POST"
252
-
)
253
-
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
254
-
255
-
req, err := s.newRequest(Method, endpoint, nil)
256
-
if err != nil {
257
-
return nil, err
258
-
}
259
-
260
-
return s.client.Do(req)
261
-
}
262
-
263
-
type UnsignedClient struct {
264
-
Url *url.URL
265
-
client *http.Client
266
-
}
267
-
268
-
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
269
-
client := &http.Client{
270
-
Timeout: 5 * time.Second,
271
-
}
272
-
273
-
scheme := "https"
274
-
if dev {
275
-
scheme = "http"
276
-
}
277
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
278
-
if err != nil {
279
-
return nil, err
280
-
}
281
-
282
-
unsignedClient := &UnsignedClient{
283
-
client: client,
284
-
Url: url,
285
-
}
286
-
287
-
return unsignedClient, nil
288
-
}
289
-
290
-
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
291
-
reqUrl := us.Url.JoinPath(endpoint)
292
-
293
-
// add query parameters
294
-
if query != nil {
295
-
reqUrl.RawQuery = query.Encode()
296
-
}
297
-
298
-
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
299
-
}
300
-
301
-
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*http.Response, error) {
302
-
const (
303
-
Method = "GET"
304
-
)
305
-
306
-
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
307
-
if ref == "" {
308
-
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
309
-
}
310
-
311
-
req, err := us.newRequest(Method, endpoint, nil, nil)
312
-
if err != nil {
313
-
return nil, err
314
-
}
315
-
316
-
return us.client.Do(req)
317
-
}
318
-
319
-
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*http.Response, error) {
320
-
const (
321
-
Method = "GET"
322
-
)
323
-
324
-
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
325
-
326
-
query := url.Values{}
327
-
query.Add("page", strconv.Itoa(page))
328
-
query.Add("per_page", strconv.Itoa(60))
329
-
330
-
req, err := us.newRequest(Method, endpoint, query, nil)
331
-
if err != nil {
332
-
return nil, err
333
-
}
334
-
335
-
return us.client.Do(req)
336
-
}
337
-
338
-
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*http.Response, error) {
339
-
const (
340
-
Method = "GET"
341
-
)
342
-
343
-
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
344
-
345
-
req, err := us.newRequest(Method, endpoint, nil, nil)
346
-
if err != nil {
347
-
return nil, err
348
-
}
349
-
350
-
return us.client.Do(req)
351
-
}
352
-
353
-
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
354
-
const (
355
-
Method = "GET"
356
-
)
357
-
358
-
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
359
-
360
-
req, err := us.newRequest(Method, endpoint, nil, nil)
361
-
if err != nil {
362
-
return nil, err
363
-
}
364
-
365
-
resp, err := us.client.Do(req)
366
-
if err != nil {
367
-
return nil, err
368
-
}
369
-
370
-
body, err := io.ReadAll(resp.Body)
371
-
if err != nil {
372
-
return nil, err
373
-
}
374
-
375
-
var result types.RepoTagsResponse
376
-
err = json.Unmarshal(body, &result)
377
-
if err != nil {
378
-
return nil, err
379
-
}
380
-
381
-
return &result, nil
382
-
}
383
-
384
-
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) {
385
-
const (
386
-
Method = "GET"
387
-
)
388
-
389
-
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
390
-
391
-
req, err := us.newRequest(Method, endpoint, nil, nil)
392
-
if err != nil {
393
-
return nil, err
394
-
}
395
-
396
-
return us.client.Do(req)
397
-
}
398
-
399
-
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
400
-
const (
401
-
Method = "GET"
402
-
)
403
-
404
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
405
-
406
-
req, err := us.newRequest(Method, endpoint, nil, nil)
407
-
if err != nil {
408
-
return nil, err
409
-
}
410
-
411
-
resp, err := us.client.Do(req)
412
-
if err != nil {
413
-
return nil, err
414
-
}
415
-
defer resp.Body.Close()
416
-
417
-
var defaultBranch types.RepoDefaultBranchResponse
418
-
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
419
-
return nil, err
420
-
}
421
-
422
-
return &defaultBranch, nil
423
-
}
424
-
425
-
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
426
-
const (
427
-
Method = "GET"
428
-
Endpoint = "/capabilities"
429
-
)
430
-
431
-
req, err := us.newRequest(Method, Endpoint, nil, nil)
432
-
if err != nil {
433
-
return nil, err
434
-
}
435
-
436
-
resp, err := us.client.Do(req)
437
-
if err != nil {
438
-
return nil, err
439
-
}
440
-
defer resp.Body.Close()
441
-
442
-
var capabilities types.Capabilities
443
-
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
444
-
return nil, err
445
-
}
446
-
447
-
return &capabilities, nil
448
-
}
449
-
450
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
451
-
const (
452
-
Method = "GET"
453
-
)
454
-
455
-
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
456
-
457
-
req, err := us.newRequest(Method, endpoint, nil, nil)
458
-
if err != nil {
459
-
return nil, fmt.Errorf("Failed to create request.")
460
-
}
461
-
462
-
compareResp, err := us.client.Do(req)
463
-
if err != nil {
464
-
return nil, fmt.Errorf("Failed to create request.")
465
-
}
466
-
defer compareResp.Body.Close()
467
-
468
-
switch compareResp.StatusCode {
469
-
case 404:
470
-
case 400:
471
-
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
472
-
}
473
-
474
-
respBody, err := io.ReadAll(compareResp.Body)
475
-
if err != nil {
476
-
log.Println("failed to compare across branches")
477
-
return nil, fmt.Errorf("Failed to compare branches.")
478
-
}
479
-
defer compareResp.Body.Close()
480
-
481
-
var formatPatchResponse types.RepoFormatPatchResponse
482
-
err = json.Unmarshal(respBody, &formatPatchResponse)
483
-
if err != nil {
484
-
log.Println("failed to unmarshal format-patch response", err)
485
-
return nil, fmt.Errorf("failed to compare branches.")
486
-
}
487
-
488
-
return &formatPatchResponse, nil
489
-
}
+32
-4
appview/state/star.go
+32
-4
appview/state/star.go
···
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
"github.com/posthog/posthog-go"
11
12
"tangled.sh/tangled.sh/core/api/tangled"
12
13
"tangled.sh/tangled.sh/core/appview"
13
14
"tangled.sh/tangled.sh/core/appview/db"
···
15
16
)
16
17
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
18
-
currentUser := s.auth.GetUser(r)
19
+
currentUser := s.oauth.GetUser(r)
19
20
20
21
subject := r.URL.Query().Get("subject")
21
22
if subject == "" {
···
29
30
return
30
31
}
31
32
32
-
client, _ := s.auth.AuthorizedClient(r)
33
+
client, err := s.oauth.AuthorizedClient(r)
34
+
if err != nil {
35
+
log.Println("failed to authorize client", err)
36
+
return
37
+
}
33
38
34
39
switch r.Method {
35
40
case http.MethodPost:
36
41
createdAt := time.Now().Format(time.RFC3339)
37
42
rkey := appview.TID()
38
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
43
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
39
44
Collection: tangled.FeedStarNSID,
40
45
Repo: currentUser.Did,
41
46
Rkey: rkey,
···
71
76
},
72
77
})
73
78
79
+
if !s.config.Core.Dev {
80
+
err = s.posthog.Enqueue(posthog.Capture{
81
+
DistinctId: currentUser.Did,
82
+
Event: "star",
83
+
Properties: posthog.Properties{"repo_at": subjectUri.String()},
84
+
})
85
+
if err != nil {
86
+
log.Println("failed to enqueue posthog event:", err)
87
+
}
88
+
}
89
+
74
90
return
75
91
case http.MethodDelete:
76
92
// find the record in the db
···
80
96
return
81
97
}
82
98
83
-
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
99
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
84
100
Collection: tangled.FeedStarNSID,
85
101
Repo: currentUser.Did,
86
102
Rkey: star.Rkey,
···
100
116
starCount, err := db.GetStarCount(s.db, subjectUri)
101
117
if err != nil {
102
118
log.Println("failed to get star count for ", subjectUri)
119
+
return
103
120
}
104
121
105
122
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
···
109
126
StarCount: starCount,
110
127
},
111
128
})
129
+
130
+
if !s.config.Core.Dev {
131
+
err = s.posthog.Enqueue(posthog.Capture{
132
+
DistinctId: currentUser.Did,
133
+
Event: "unstar",
134
+
Properties: posthog.Properties{"repo_at": subjectUri.String()},
135
+
})
136
+
if err != nil {
137
+
log.Println("failed to enqueue posthog event:", err)
138
+
}
139
+
}
112
140
113
141
return
114
142
}
+96
-116
appview/state/state.go
+96
-116
appview/state/state.go
···
17
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
18
securejoin "github.com/cyphar/filepath-securejoin"
19
19
"github.com/go-chi/chi/v5"
20
+
"github.com/posthog/posthog-go"
20
21
"tangled.sh/tangled.sh/core/api/tangled"
21
22
"tangled.sh/tangled.sh/core/appview"
22
-
"tangled.sh/tangled.sh/core/appview/auth"
23
23
"tangled.sh/tangled.sh/core/appview/db"
24
+
"tangled.sh/tangled.sh/core/appview/oauth"
24
25
"tangled.sh/tangled.sh/core/appview/pages"
25
26
"tangled.sh/tangled.sh/core/jetstream"
27
+
"tangled.sh/tangled.sh/core/knotclient"
26
28
"tangled.sh/tangled.sh/core/rbac"
27
29
)
28
30
29
31
type State struct {
30
32
db *db.DB
31
-
auth *auth.Auth
33
+
oauth *oauth.OAuth
32
34
enforcer *rbac.Enforcer
33
-
tidClock *syntax.TIDClock
35
+
tidClock syntax.TIDClock
34
36
pages *pages.Pages
35
37
resolver *appview.Resolver
38
+
posthog posthog.Client
36
39
jc *jetstream.JetstreamClient
37
40
config *appview.Config
38
41
}
39
42
40
43
func Make(config *appview.Config) (*State, error) {
41
-
d, err := db.Make(config.DbPath)
44
+
d, err := db.Make(config.Core.DbPath)
42
45
if err != nil {
43
46
return nil, err
44
47
}
45
48
46
-
auth, err := auth.Make(config.CookieSecret)
47
-
if err != nil {
48
-
return nil, err
49
-
}
50
-
51
-
enforcer, err := rbac.NewEnforcer(config.DbPath)
49
+
enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
52
50
if err != nil {
53
51
return nil, err
54
52
}
···
59
57
60
58
resolver := appview.NewResolver()
61
59
60
+
oauth := oauth.NewOAuth(d, config)
61
+
62
+
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
63
+
if err != nil {
64
+
return nil, fmt.Errorf("failed to create posthog client: %w", err)
65
+
}
66
+
62
67
wrapper := db.DbWrapper{d}
63
68
jc, err := jetstream.NewJetstreamClient(
64
-
config.JetstreamEndpoint,
69
+
config.Jetstream.Endpoint,
65
70
"appview",
66
-
[]string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID},
71
+
[]string{
72
+
tangled.GraphFollowNSID,
73
+
tangled.FeedStarNSID,
74
+
tangled.PublicKeyNSID,
75
+
tangled.RepoArtifactNSID,
76
+
tangled.ActorProfileNSID,
77
+
},
67
78
nil,
68
79
slog.Default(),
69
80
wrapper,
···
72
83
if err != nil {
73
84
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
74
85
}
75
-
err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper))
86
+
err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer))
76
87
if err != nil {
77
88
return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
78
89
}
79
90
80
91
state := &State{
81
92
d,
82
-
auth,
93
+
oauth,
83
94
enforcer,
84
95
clock,
85
96
pgs,
86
97
resolver,
98
+
posthog,
87
99
jc,
88
100
config,
89
101
}
···
95
107
return c.Next().String()
96
108
}
97
109
98
-
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
99
-
ctx := r.Context()
100
-
101
-
switch r.Method {
102
-
case http.MethodGet:
103
-
err := s.pages.Login(w, pages.LoginParams{})
104
-
if err != nil {
105
-
log.Printf("rendering login page: %s", err)
106
-
}
107
-
108
-
return
109
-
case http.MethodPost:
110
-
handle := strings.TrimPrefix(r.FormValue("handle"), "@")
111
-
appPassword := r.FormValue("app_password")
112
-
113
-
resolved, err := s.resolver.ResolveIdent(ctx, handle)
114
-
if err != nil {
115
-
log.Println("failed to resolve handle:", err)
116
-
s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
117
-
return
118
-
}
119
-
120
-
atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword)
121
-
if err != nil {
122
-
s.pages.Notice(w, "login-msg", "Invalid handle or password.")
123
-
return
124
-
}
125
-
sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession}
126
-
127
-
err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint())
128
-
if err != nil {
129
-
s.pages.Notice(w, "login-msg", "Failed to login, try again later.")
130
-
return
131
-
}
132
-
133
-
log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did)
134
-
135
-
did := resolved.DID.String()
136
-
defaultKnot := "knot1.tangled.sh"
137
-
138
-
go func() {
139
-
log.Printf("adding %s to default knot", did)
140
-
err = s.enforcer.AddMember(defaultKnot, did)
141
-
if err != nil {
142
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
143
-
return
144
-
}
145
-
err = s.enforcer.E.SavePolicy()
146
-
if err != nil {
147
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
148
-
return
149
-
}
150
-
151
-
secret, err := db.GetRegistrationKey(s.db, defaultKnot)
152
-
if err != nil {
153
-
log.Println("failed to get registration key for knot1.tangled.sh")
154
-
return
155
-
}
156
-
signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Dev)
157
-
resp, err := signedClient.AddMember(did)
158
-
if err != nil {
159
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
160
-
return
161
-
}
162
-
163
-
if resp.StatusCode != http.StatusNoContent {
164
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
165
-
return
166
-
}
167
-
}()
168
-
169
-
s.pages.HxRedirect(w, "/")
170
-
return
171
-
}
172
-
}
173
-
174
110
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
175
-
s.auth.ClearSession(r, w)
111
+
s.oauth.ClearSession(r, w)
176
112
w.Header().Set("HX-Redirect", "/login")
177
113
w.WriteHeader(http.StatusSeeOther)
178
114
}
179
115
180
116
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
181
-
user := s.auth.GetUser(r)
117
+
user := s.oauth.GetUser(r)
182
118
183
119
timeline, err := db.MakeTimeline(s.db)
184
120
if err != nil {
···
229
165
230
166
return
231
167
case http.MethodPost:
232
-
session, err := s.auth.Store.Get(r, appview.SessionName)
168
+
session, err := s.oauth.Store.Get(r, appview.SessionName)
233
169
if err != nil || session.IsNew {
234
170
log.Println("unauthorized attempt to generate registration key")
235
171
http.Error(w, "Forbidden", http.StatusUnauthorized)
···
291
227
292
228
// create a signed request and check if a node responds to that
293
229
func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
294
-
user := s.auth.GetUser(r)
230
+
user := s.oauth.GetUser(r)
295
231
296
232
domain := chi.URLParam(r, "domain")
297
233
if domain == "" {
···
306
242
return
307
243
}
308
244
309
-
client, err := NewSignedClient(domain, secret, s.config.Dev)
245
+
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
310
246
if err != nil {
311
247
log.Println("failed to create client to ", domain)
312
248
}
···
415
351
return
416
352
}
417
353
418
-
user := s.auth.GetUser(r)
354
+
user := s.oauth.GetUser(r)
419
355
reg, err := db.RegistrationByDomain(s.db, domain)
420
356
if err != nil {
421
357
w.Write([]byte("failed to pull up registration info"))
···
463
399
// get knots registered by this user
464
400
func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
465
401
// for now, this is just pubkeys
466
-
user := s.auth.GetUser(r)
402
+
user := s.oauth.GetUser(r)
467
403
registrations, err := db.RegistrationsByDid(s.db, user.Did)
468
404
if err != nil {
469
405
log.Println(err)
···
516
452
log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
517
453
518
454
// announce this relation into the firehose, store into owners' pds
519
-
client, _ := s.auth.AuthorizedClient(r)
520
-
currentUser := s.auth.GetUser(r)
455
+
client, err := s.oauth.AuthorizedClient(r)
456
+
if err != nil {
457
+
http.Error(w, "failed to authorize client", http.StatusInternalServerError)
458
+
return
459
+
}
460
+
currentUser := s.oauth.GetUser(r)
521
461
createdAt := time.Now().Format(time.RFC3339)
522
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
462
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
523
463
Collection: tangled.KnotMemberNSID,
524
464
Repo: currentUser.Did,
525
465
Rkey: appview.TID(),
···
544
484
return
545
485
}
546
486
547
-
ksClient, err := NewSignedClient(domain, secret, s.config.Dev)
487
+
ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
548
488
if err != nil {
549
489
log.Println("failed to create client to ", domain)
550
490
return
···
573
513
func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
574
514
}
575
515
516
+
func validateRepoName(name string) error {
517
+
// check for path traversal attempts
518
+
if name == "." || name == ".." ||
519
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
520
+
return fmt.Errorf("Repository name contains invalid path characters")
521
+
}
522
+
523
+
// check for sequences that could be used for traversal when normalized
524
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
525
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
526
+
return fmt.Errorf("Repository name contains invalid path sequence")
527
+
}
528
+
529
+
// then continue with character validation
530
+
for _, char := range name {
531
+
if !((char >= 'a' && char <= 'z') ||
532
+
(char >= 'A' && char <= 'Z') ||
533
+
(char >= '0' && char <= '9') ||
534
+
char == '-' || char == '_' || char == '.') {
535
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
536
+
}
537
+
}
538
+
539
+
// additional check to prevent multiple sequential dots
540
+
if strings.Contains(name, "..") {
541
+
return fmt.Errorf("Repository name cannot contain sequential dots")
542
+
}
543
+
544
+
// if all checks pass
545
+
return nil
546
+
}
547
+
576
548
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
577
549
switch r.Method {
578
550
case http.MethodGet:
579
-
user := s.auth.GetUser(r)
551
+
user := s.oauth.GetUser(r)
580
552
knots, err := s.enforcer.GetDomainsForUser(user.Did)
581
553
if err != nil {
582
554
s.pages.Notice(w, "repo", "Invalid user account.")
···
589
561
})
590
562
591
563
case http.MethodPost:
592
-
user := s.auth.GetUser(r)
564
+
user := s.oauth.GetUser(r)
593
565
594
566
domain := r.FormValue("domain")
595
567
if domain == "" {
···
603
575
return
604
576
}
605
577
606
-
// Check for valid repository name (GitHub-like rules)
607
-
// No spaces, only alphanumeric characters, dashes, and underscores
608
-
for _, char := range repoName {
609
-
if !((char >= 'a' && char <= 'z') ||
610
-
(char >= 'A' && char <= 'Z') ||
611
-
(char >= '0' && char <= '9') ||
612
-
char == '-' || char == '_' || char == '.') {
613
-
s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.")
614
-
return
615
-
}
578
+
if err := validateRepoName(repoName); err != nil {
579
+
s.pages.Notice(w, "repo", err.Error())
580
+
return
616
581
}
617
582
618
583
defaultBranch := r.FormValue("branch")
···
640
605
return
641
606
}
642
607
643
-
client, err := NewSignedClient(domain, secret, s.config.Dev)
608
+
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
644
609
if err != nil {
645
610
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
646
611
return
···
655
620
Description: description,
656
621
}
657
622
658
-
xrpcClient, _ := s.auth.AuthorizedClient(r)
623
+
xrpcClient, err := s.oauth.AuthorizedClient(r)
624
+
if err != nil {
625
+
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
626
+
return
627
+
}
659
628
660
629
createdAt := time.Now().Format(time.RFC3339)
661
-
atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
630
+
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
662
631
Collection: tangled.RepoNSID,
663
632
Repo: user.Did,
664
633
Rkey: rkey,
···
736
705
log.Println("failed to update ACLs", err)
737
706
http.Error(w, err.Error(), http.StatusInternalServerError)
738
707
return
708
+
}
709
+
710
+
if !s.config.Core.Dev {
711
+
err = s.posthog.Enqueue(posthog.Capture{
712
+
DistinctId: user.Did,
713
+
Event: "new_repo",
714
+
Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri},
715
+
})
716
+
if err != nil {
717
+
log.Println("failed to enqueue posthog event:", err)
718
+
}
739
719
}
740
720
741
721
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
+1
-1
appview/tid.go
+1
-1
appview/tid.go
+89
appview/xrpcclient/xrpc.go
+89
appview/xrpcclient/xrpc.go
···
1
+
package xrpcclient
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"io"
7
+
8
+
"github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/xrpc"
10
+
oauth "github.com/haileyok/atproto-oauth-golang"
11
+
)
12
+
13
+
type Client struct {
14
+
*oauth.XrpcClient
15
+
authArgs *oauth.XrpcAuthedRequestArgs
16
+
}
17
+
18
+
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
19
+
return &Client{
20
+
XrpcClient: client,
21
+
authArgs: authArgs,
22
+
}
23
+
}
24
+
25
+
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
26
+
var out atproto.RepoPutRecord_Output
27
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
28
+
return nil, err
29
+
}
30
+
31
+
return &out, nil
32
+
}
33
+
34
+
func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) {
35
+
var out atproto.RepoApplyWrites_Output
36
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return &out, nil
41
+
}
42
+
43
+
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
44
+
var out atproto.RepoGetRecord_Output
45
+
46
+
params := map[string]interface{}{
47
+
"cid": cid,
48
+
"collection": collection,
49
+
"repo": repo,
50
+
"rkey": rkey,
51
+
}
52
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
53
+
return nil, err
54
+
}
55
+
56
+
return &out, nil
57
+
}
58
+
59
+
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
60
+
var out atproto.RepoUploadBlob_Output
61
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
62
+
return nil, err
63
+
}
64
+
65
+
return &out, nil
66
+
}
67
+
68
+
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
69
+
buf := new(bytes.Buffer)
70
+
71
+
params := map[string]interface{}{
72
+
"cid": cid,
73
+
"did": did,
74
+
}
75
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
76
+
return nil, err
77
+
}
78
+
79
+
return buf.Bytes(), nil
80
+
}
81
+
82
+
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
83
+
var out atproto.RepoDeleteRecord_Output
84
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
85
+
return nil, err
86
+
}
87
+
88
+
return &out, nil
89
+
}
+2
-1
camo/src/mimetypes.json
+2
-1
camo/src/mimetypes.json
+2
-2
cmd/appview/main.go
+2
-2
cmd/appview/main.go
+1
cmd/gen.go
+1
cmd/gen.go
+39
cmd/genjwks/main.go
+39
cmd/genjwks/main.go
···
1
+
// adapted from https://github.com/haileyok/atproto-oauth-golang
2
+
3
+
package main
4
+
5
+
import (
6
+
"crypto/ecdsa"
7
+
"crypto/elliptic"
8
+
"crypto/rand"
9
+
"encoding/json"
10
+
"fmt"
11
+
"time"
12
+
13
+
"github.com/lestrrat-go/jwx/v2/jwk"
14
+
)
15
+
16
+
func main() {
17
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
18
+
if err != nil {
19
+
panic(err)
20
+
}
21
+
22
+
key, err := jwk.FromRaw(privKey)
23
+
if err != nil {
24
+
panic(err)
25
+
}
26
+
27
+
kid := fmt.Sprintf("%d", time.Now().Unix())
28
+
29
+
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
30
+
panic(err)
31
+
}
32
+
33
+
b, err := json.Marshal(key)
34
+
if err != nil {
35
+
panic(err)
36
+
}
37
+
38
+
fmt.Println(string(b))
39
+
}
+2
-4
docker/Dockerfile
+2
-4
docker/Dockerfile
···
42
42
COPY docker/rootfs/ .
43
43
44
44
RUN chown root:root /usr/local/libexec/tangled-keyfetch && \
45
-
chmod 755 /usr/local/libexec/tangled-keyfetch && \
46
-
chown git:git /home/git/repoguard && \
47
-
chown git:git /app && chown git:git /home/git/repositories
45
+
chmod 755 /usr/local/libexec/tangled-keyfetch
48
46
49
47
EXPOSE 22
50
48
EXPOSE 5555
51
49
52
-
ENTRYPOINT ["/init"]
50
+
ENTRYPOINT ["/bin/sh", "-c", "chown git:git /home/git/repoguard && chown git:git /app && chown git:git /home/git/repositories && /init"]
+17
-1
docker/docker-compose.yml
+17
-1
docker/docker-compose.yml
···
13
13
- "./repositories:/home/git/repositories"
14
14
- "./server:/app"
15
15
ports:
16
-
- "5555:5555"
17
16
- "2222:22"
17
+
frontend:
18
+
image: caddy:2-alpine
19
+
command: >
20
+
caddy
21
+
reverse-proxy
22
+
--from ${KNOT_SERVER_HOSTNAME}
23
+
--to knot:5555
24
+
depends_on:
25
+
- knot
26
+
ports:
27
+
- "443:443"
28
+
- "443:443/udp"
29
+
volumes:
30
+
- caddy_data:/data
31
+
restart: always
32
+
volumes:
33
+
caddy_data:
+66
-9
docs/contributing.md
+66
-9
docs/contributing.md
···
4
4
5
5
We follow a commit style similar to the Go project. Please keep commits:
6
6
7
-
* **atomic**: each commit should represent one logical change
7
+
* **atomic**: each commit should represent one logical change
8
8
* **descriptive**: the commit message should clearly describe what the
9
9
change does and why it's needed
10
10
11
11
### message format
12
12
13
-
```
13
+
```
14
14
<service/top-level directory>: <affected package/directory>: <short summary of change>
15
15
16
16
···
26
26
appview: state: fix token expiry check in middleware
27
27
28
28
The previous check did not account for clock drift, leading to premature
29
-
token invalidation.
29
+
token invalidation.
30
30
```
31
31
32
32
```
33
33
knotserver: git/service: improve error checking in upload-pack
34
34
```
35
+
36
+
The affected package/directory can be truncated down to just the relevant dir
37
+
should it be far too long. For example `pages/templates/repo/fragments` can
38
+
simply be `repo/fragments`.
35
39
36
40
### general notes
37
41
···
52
56
Small fixes like typos, minor bugs, or trivial refactors can be
53
57
submitted directly as PRs.
54
58
55
-
For larger changesโespecially those introducing new features,
56
-
significant refactoring, or altering system behaviorโplease open a
57
-
proposal first. This helps us evaluate the scope, design, and potential
58
-
impact before implementation.
59
+
For larger changesโespecially those introducing new features, significant
60
+
refactoring, or altering system behaviorโplease open a proposal first. This
61
+
helps us evaluate the scope, design, and potential impact before implementation.
59
62
60
63
### proposal format
61
64
62
65
Create a new issue titled:
63
66
64
-
```
65
-
proposal: <affected scope>: <summary of change>
67
+
```
68
+
proposal: <affected scope>: <summary of change>
66
69
```
67
70
68
71
In the description, explain:
···
74
77
75
78
We'll use the issue thread to discuss and refine the idea before moving
76
79
forward.
80
+
81
+
## developer certificate of origin (DCO)
82
+
83
+
We require all contributors to certify that they have the right to
84
+
submit the code they're contributing. To do this, we follow the
85
+
[Developer Certificate of Origin
86
+
(DCO)](https://developercertificate.org/).
87
+
88
+
By signing your commits, you're stating that the contribution is your
89
+
own work, or that you have the right to submit it under the project's
90
+
license. This helps us keep things clean and legally sound.
91
+
92
+
To sign your commit, just add the `-s` flag when committing:
93
+
94
+
```sh
95
+
git commit -s -m "your commit message"
96
+
```
97
+
98
+
This appends a line like:
99
+
100
+
```
101
+
Signed-off-by: Your Name <your.email@example.com>
102
+
```
103
+
104
+
We won't merge commits if they aren't signed off. If you forget, you can
105
+
amend the last commit like this:
106
+
107
+
```sh
108
+
git commit --amend -s
109
+
```
110
+
111
+
If you're submitting a PR with multiple commits, make sure each one is
112
+
signed.
113
+
114
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to
115
+
your jj config:
116
+
117
+
```
118
+
ui.should-sign-off = true
119
+
```
120
+
121
+
and to your `templates.draft_commit_description`, add the following `if`
122
+
block:
123
+
124
+
```
125
+
if(
126
+
config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()),
127
+
"\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">",
128
+
),
129
+
```
130
+
131
+
Refer to the [jj
132
+
documentation](https://jj-vcs.github.io/jj/latest/config/#default-description)
133
+
for more information.
+72
docs/hacking.md
+72
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
+
## running a knotserver
36
+
37
+
An end-to-end knotserver setup requires setting up a machine
38
+
with `sshd`, `repoguard`, `keyfetch`, a git user, which is
39
+
quite cumbersome and so the nix flake provides a
40
+
`nixosConfiguration` to do so.
41
+
42
+
To begin, head to `http://localhost:3000` in the browser and
43
+
generate a knotserver secret. Replace the existing secret in
44
+
`flake.nix` with the newly generated secret.
45
+
46
+
You can now start a lightweight NixOS VM using
47
+
`nixos-shell` like so:
48
+
49
+
```bash
50
+
QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM
51
+
52
+
# hit Ctrl-a + c + q to exit the VM
53
+
```
54
+
55
+
This starts a knotserver on port 6000 with `ssh` exposed on
56
+
port 2222. You can push repositories to this VM with this
57
+
ssh config block on your main machine:
58
+
59
+
```bash
60
+
Host nixos-shell
61
+
Hostname localhost
62
+
Port 2222
63
+
User git
64
+
IdentityFile ~/.ssh/my_tangled_key
65
+
```
66
+
67
+
Set up a remote called `local-dev` on a git repo:
68
+
69
+
```bash
70
+
git remote add local-dev git@nixos-shell:user/repo
71
+
git push local-dev main
72
+
```
+5
-5
flake.lock
+5
-5
flake.lock
···
64
64
"inter-fonts-src": {
65
65
"flake": false,
66
66
"locked": {
67
-
"lastModified": 1731680160,
67
+
"lastModified": 1731687360,
68
68
"narHash": "sha256-5vdKKvHAeZi6igrfpbOdhZlDX2/5+UvzlnCQV6DdqoQ=",
69
69
"type": "tarball",
70
70
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
···
89
89
},
90
90
"nixpkgs": {
91
91
"locked": {
92
-
"lastModified": 1746055187,
93
-
"narHash": "sha256-3dqArYSMP9hM7Qpy5YWhnSjiqniSaT2uc5h2Po7tmg0=",
92
+
"lastModified": 1746904237,
93
+
"narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=",
94
94
"owner": "nixos",
95
95
"repo": "nixpkgs",
96
-
"rev": "3e362ce63e16b9572d8c2297c04f7c19ab6725a5",
96
+
"rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956",
97
97
"type": "github"
98
98
},
99
99
"original": {
100
100
"owner": "nixos",
101
-
"ref": "nixos-24.11",
101
+
"ref": "nixos-unstable",
102
102
"repo": "nixpkgs",
103
103
"type": "github"
104
104
}
+13
-10
flake.nix
+13
-10
flake.nix
···
2
2
description = "atproto github";
3
3
4
4
inputs = {
5
-
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
5
+
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6
6
indigo = {
7
7
url = "github:oppiliappan/indigo";
8
8
flake = false;
···
49
49
inherit (gitignore.lib) gitignoreSource;
50
50
in {
51
51
overlays.default = final: prev: let
52
-
goModHash = "sha256-CmBuvv3duQQoc8iTW4244w1rYLGeqMQS+qQ3wwReZZg=";
52
+
goModHash = "sha256-mzM0B0ObAahznsL0JXMkFWN1Oix/ObOErUPH31xUMjM=";
53
53
buildCmdPackage = name:
54
54
final.buildGoModule {
55
55
pname = name;
···
57
57
src = gitignoreSource ./.;
58
58
subPackages = ["cmd/${name}"];
59
59
vendorHash = goModHash;
60
-
CGO_ENABLED = 0;
60
+
env.CGO_ENABLED = 0;
61
61
};
62
62
in {
63
63
indigo-lexgen = final.buildGoModule {
···
88
88
doCheck = false;
89
89
subPackages = ["cmd/appview"];
90
90
vendorHash = goModHash;
91
-
CGO_ENABLED = 1;
91
+
env.CGO_ENABLED = 1;
92
92
stdenv = pkgsStatic.stdenv;
93
93
};
94
94
···
111
111
112
112
runHook postInstall
113
113
'';
114
-
CGO_ENABLED = 1;
114
+
env.CGO_ENABLED = 1;
115
115
};
116
116
knotserver-unwrapped = final.pkgsStatic.buildGoModule {
117
117
pname = "knotserver";
···
119
119
src = gitignoreSource ./.;
120
120
subPackages = ["cmd/knotserver"];
121
121
vendorHash = goModHash;
122
-
CGO_ENABLED = 1;
122
+
env.CGO_ENABLED = 1;
123
123
};
124
124
repoguard = buildCmdPackage "repoguard";
125
125
keyfetch = buildCmdPackage "keyfetch";
126
+
genjwks = buildCmdPackage "genjwks";
126
127
};
127
128
packages = forAllSystems (system: {
128
129
inherit
···
133
134
knotserver-unwrapped
134
135
repoguard
135
136
keyfetch
137
+
genjwks
136
138
;
137
139
});
138
140
defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview);
···
162
164
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
163
165
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
164
166
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
167
+
export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)"
165
168
'';
169
+
env.CGO_ENABLED = 1;
166
170
};
167
171
});
168
172
apps = forAllSystems (system: let
···
170
174
air-watcher = name:
171
175
pkgs.writeShellScriptBin "run"
172
176
''
173
-
TANGLED_DEV=true ${pkgs.air}/bin/air -c /dev/null \
174
-
-build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
177
+
${pkgs.air}/bin/air -c /dev/null \
178
+
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
175
179
-build.bin "./out/${name}.out" \
176
180
-build.stop_on_error "true" \
177
181
-build.include_ext "go"
···
431
435
g = config.services.tangled-knotserver.gitUser;
432
436
in [
433
437
"d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first
434
-
"f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=679f15000084699abc6a20d3ef449efa3656583f38e456a08f0638250688ff2e"
438
+
"f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=38a7c3237c2a585807e06a5bcfac92eb39442063f3da306b7acb15cfdc51d19d"
435
439
];
436
440
services.tangled-knotserver = {
437
441
enable = true;
···
446
450
};
447
451
};
448
452
}
449
-
+33
-22
go.mod
+33
-22
go.mod
···
1
1
module tangled.sh/tangled.sh/core
2
2
3
-
go 1.23.0
3
+
go 1.24.0
4
4
5
-
toolchain go1.23.6
5
+
toolchain go1.24.3
6
6
7
7
require (
8
8
github.com/Blank-Xu/sql-adapter v1.1.1
9
9
github.com/alecthomas/chroma/v2 v2.15.0
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20
11
+
github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/casbin/casbin/v2 v2.103.0
14
14
github.com/cyphar/filepath-securejoin v0.4.1
15
15
github.com/dgraph-io/ristretto v0.2.0
16
16
github.com/dustin/go-humanize v1.0.1
17
-
github.com/gliderlabs/ssh v0.3.5
17
+
github.com/gliderlabs/ssh v0.3.8
18
18
github.com/go-chi/chi/v5 v5.2.0
19
+
github.com/go-enry/go-enry/v2 v2.9.2
19
20
github.com/go-git/go-git/v5 v5.14.0
20
21
github.com/google/uuid v1.6.0
21
22
github.com/gorilla/sessions v1.4.0
23
+
github.com/haileyok/atproto-oauth-golang v0.0.2
22
24
github.com/ipfs/go-cid v0.5.0
25
+
github.com/lestrrat-go/jwx/v2 v2.0.12
23
26
github.com/mattn/go-sqlite3 v1.14.24
24
27
github.com/microcosm-cc/bluemonday v1.0.27
28
+
github.com/posthog/posthog-go v1.5.5
25
29
github.com/resend/resend-go/v2 v2.15.0
26
30
github.com/sethvargo/go-envconfig v1.1.0
27
31
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
28
32
github.com/yuin/goldmark v1.4.13
33
+
golang.org/x/net v0.39.0
29
34
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
30
35
)
31
36
32
37
require (
38
+
dario.cat/mergo v1.0.1 // indirect
33
39
github.com/Microsoft/go-winio v0.6.2 // indirect
34
-
github.com/ProtonMail/go-crypto v1.1.6 // indirect
35
-
github.com/acomagu/bufpipe v1.0.4 // indirect
40
+
github.com/ProtonMail/go-crypto v1.2.0 // indirect
36
41
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
37
42
github.com/aymerick/douceur v0.2.0 // indirect
38
43
github.com/beorn7/perks v1.0.1 // indirect
···
41
46
github.com/casbin/govaluate v1.3.0 // indirect
42
47
github.com/cespare/xxhash/v2 v2.3.0 // indirect
43
48
github.com/cloudflare/circl v1.6.0 // indirect
44
-
github.com/davecgh/go-spew v1.1.1 // indirect
49
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
45
50
github.com/dlclark/regexp2 v1.11.5 // indirect
46
51
github.com/emirpasic/gods v1.18.1 // indirect
47
52
github.com/felixge/httpsnoop v1.0.4 // indirect
53
+
github.com/go-enry/go-oniguruma v1.2.1 // indirect
48
54
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
49
55
github.com/go-git/go-billy/v5 v5.6.2 // indirect
50
-
github.com/go-logr/logr v1.4.1 // indirect
56
+
github.com/go-logr/logr v1.4.2 // indirect
51
57
github.com/go-logr/stdr v1.2.2 // indirect
52
58
github.com/goccy/go-json v0.10.2 // indirect
53
59
github.com/gogo/protobuf v1.3.2 // indirect
60
+
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
61
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
54
62
github.com/gorilla/css v1.0.1 // indirect
55
63
github.com/gorilla/securecookie v1.1.2 // indirect
56
64
github.com/gorilla/websocket v1.5.1 // indirect
···
58
66
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
59
67
github.com/hashicorp/golang-lru v1.0.2 // indirect
60
68
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
61
-
github.com/imdario/mergo v0.3.16 // indirect
62
69
github.com/ipfs/bbloom v0.0.4 // indirect
63
70
github.com/ipfs/go-block-format v0.2.0 // indirect
64
71
github.com/ipfs/go-datastore v0.6.0 // indirect
···
70
77
github.com/ipfs/go-log v1.0.5 // indirect
71
78
github.com/ipfs/go-log/v2 v2.5.1 // indirect
72
79
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
73
-
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
74
80
github.com/jbenet/goprocess v0.1.4 // indirect
75
81
github.com/kevinburke/ssh_config v1.2.0 // indirect
76
82
github.com/klauspost/compress v1.17.9 // indirect
77
83
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
84
+
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
85
+
github.com/lestrrat-go/httpcc v1.0.1 // indirect
86
+
github.com/lestrrat-go/httprc v1.0.4 // indirect
87
+
github.com/lestrrat-go/iter v1.0.2 // indirect
88
+
github.com/lestrrat-go/option v1.0.1 // indirect
78
89
github.com/mattn/go-isatty v0.0.20 // indirect
79
90
github.com/minio/sha256-simd v1.0.1 // indirect
80
91
github.com/mr-tron/base58 v1.2.0 // indirect
···
86
97
github.com/opentracing/opentracing-go v1.2.0 // indirect
87
98
github.com/pjbgf/sha1cd v0.3.2 // indirect
88
99
github.com/pkg/errors v0.9.1 // indirect
89
-
github.com/pmezard/go-difflib v1.0.0 // indirect
90
100
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
91
101
github.com/prometheus/client_golang v1.19.1 // indirect
92
102
github.com/prometheus/client_model v0.6.1 // indirect
93
103
github.com/prometheus/common v0.54.0 // indirect
94
104
github.com/prometheus/procfs v0.15.1 // indirect
95
-
github.com/sergi/go-diff v1.3.1 // indirect
96
-
github.com/skeema/knownhosts v1.3.1 // indirect
105
+
github.com/segmentio/asm v1.2.0 // indirect
106
+
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
97
107
github.com/spaolacci/murmur3 v1.1.0 // indirect
98
-
github.com/stretchr/testify v1.10.0 // indirect
99
-
github.com/xanzy/ssh-agent v0.3.3 // indirect
100
108
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
101
109
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
102
110
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
103
-
go.opentelemetry.io/otel v1.21.0 // indirect
104
-
go.opentelemetry.io/otel/metric v1.21.0 // indirect
105
-
go.opentelemetry.io/otel/trace v1.21.0 // indirect
111
+
go.opentelemetry.io/otel v1.29.0 // indirect
112
+
go.opentelemetry.io/otel/metric v1.29.0 // indirect
113
+
go.opentelemetry.io/otel/trace v1.29.0 // indirect
106
114
go.uber.org/atomic v1.11.0 // indirect
107
115
go.uber.org/multierr v1.11.0 // indirect
108
116
go.uber.org/zap v1.26.0 // indirect
109
117
golang.org/x/crypto v0.37.0 // indirect
110
-
golang.org/x/net v0.39.0 // indirect
118
+
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
111
119
golang.org/x/sys v0.32.0 // indirect
112
-
golang.org/x/time v0.5.0 // indirect
120
+
golang.org/x/time v0.8.0 // indirect
113
121
google.golang.org/protobuf v1.34.2 // indirect
114
122
gopkg.in/warnings.v0 v0.1.2 // indirect
115
-
gopkg.in/yaml.v3 v3.0.1 // indirect
116
123
lukechampine.com/blake3 v1.2.1 // indirect
117
124
)
118
125
119
126
replace github.com/sergi/go-diff => github.com/sergi/go-diff v1.1.0
120
127
121
-
replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.6.1
128
+
replace github.com/go-git/go-git/v5 => github.com/oppiliappan/go-git/v5 v5.17.0
129
+
130
+
replace github.com/bluekeyes/go-gitdiff => tangled.sh/oppi.li/go-gitdiff v0.8.2
131
+
132
+
replace github.com/alecthomas/chroma/v2 => github.com/oppiliappan/chroma/v2 v2.17.0
122
133
123
134
// from bluesky-social/indigo
124
135
replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4
+90
-89
go.sum
+90
-89
go.sum
···
1
+
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
2
+
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
1
3
github.com/Blank-Xu/sql-adapter v1.1.1 h1:+g7QXU9sl/qT6Po97teMpf3GjAO0X9aFaqgSePXvYko=
2
4
github.com/Blank-Xu/sql-adapter v1.1.1/go.mod h1:o2g8EZhZ3TudnYEGDkoU+3jCTCgDgx1o/Ig5ajKkaLY=
3
5
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
4
-
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
5
6
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
6
7
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
7
-
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
8
-
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
9
-
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
10
-
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
11
-
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
8
+
github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs=
9
+
github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
10
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
11
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
-
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
15
-
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
16
12
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
17
13
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
18
14
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
···
24
20
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
25
21
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
26
22
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
27
-
github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI=
28
-
github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
29
-
github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20 h1:yHusfYYi8odoCcsI6AurU+dRWb7itHAQNwt3/Rl9Vfs=
30
-
github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20/go.mod h1:Qp4YqWf+AQ3TwQCxV5Ls8O2tXE55zVTGVs3zTmn7BOg=
23
+
github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk=
24
+
github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA=
31
25
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
32
26
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
33
27
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
34
28
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
35
29
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
36
-
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
37
30
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
38
31
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
39
32
github.com/casbin/casbin/v2 v2.100.0/go.mod h1:LO7YPez4dX3LgoTCqSQAleQDo0S0BeZBDxYnPUl95Ng=
···
44
37
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
45
38
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
46
39
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
47
-
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
48
40
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
49
41
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
50
42
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
51
-
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
52
43
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
53
44
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
54
45
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
55
-
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
56
46
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
47
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
48
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
49
+
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
50
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
51
+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
57
52
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
58
53
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
59
54
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
62
57
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
63
58
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
64
59
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
60
+
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
61
+
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
65
62
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
66
63
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
67
64
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
68
65
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
69
-
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
70
-
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
66
+
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
67
+
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
71
68
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
72
69
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
73
-
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
70
+
github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY=
71
+
github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
72
+
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
73
+
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
74
74
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
75
75
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
76
-
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
77
-
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
78
76
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
79
77
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
80
-
github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
81
-
github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
82
-
github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
83
-
github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
78
+
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
79
+
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
80
+
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
81
+
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
84
82
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
85
-
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
86
-
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
83
+
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
84
+
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
87
85
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
88
86
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
89
87
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
···
91
89
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
92
90
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
93
91
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
92
+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
93
+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
94
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
95
+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
94
96
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
95
97
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
96
-
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
97
98
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
98
99
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
99
100
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
···
111
112
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
112
113
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
113
114
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
115
+
github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8=
116
+
github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8=
114
117
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
115
118
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
116
119
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
123
126
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
124
127
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
125
128
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
126
-
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
127
-
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
128
-
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
129
129
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
130
130
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
131
131
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
···
154
154
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
155
155
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
156
156
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
157
-
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
158
-
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
159
157
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
160
158
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
161
-
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
159
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
160
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
162
161
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
163
162
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
164
163
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
170
169
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
171
170
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
172
171
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
173
-
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
174
172
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
175
173
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
176
174
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
177
175
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
178
176
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
179
177
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
180
-
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
181
-
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
178
+
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
179
+
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
180
+
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
181
+
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
182
+
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
183
+
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
184
+
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
185
+
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
186
+
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
187
+
github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA=
188
+
github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ=
189
+
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
190
+
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
191
+
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
182
192
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
183
193
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
184
194
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
···
188
198
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
189
199
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
190
200
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
191
-
github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM=
192
201
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
193
202
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
194
203
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
···
201
210
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
202
211
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
203
212
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
204
-
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
205
213
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
206
214
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
207
215
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
208
216
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
209
-
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
217
+
github.com/oppiliappan/chroma/v2 v2.17.0 h1:Qi8qnCvhCn8VxwD+BGpt7n5BdLX32/2kRBlT7hAR5Ko=
218
+
github.com/oppiliappan/chroma/v2 v2.17.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
219
+
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
220
+
github.com/oppiliappan/go-git/v5 v5.17.0/go.mod h1:q/FE8C3SPMoRN7LoH9vRFiBzidAOBWJPS1CqVS8DN+w=
210
221
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
211
222
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
212
223
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
213
224
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
214
225
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
215
-
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
216
226
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
227
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
228
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
217
229
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
218
230
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
231
+
github.com/posthog/posthog-go v1.5.5 h1:2o3j7IrHbTIfxRtj4MPaXKeimuTYg49onNzNBZbwksM=
232
+
github.com/posthog/posthog-go v1.5.5/go.mod h1:3RqUmSnPuwmeVj/GYrS75wNGqcAKdpODiwc83xZWgdE=
219
233
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
220
234
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
221
235
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
···
227
241
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
228
242
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
229
243
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
230
-
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
231
-
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
244
+
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
245
+
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
232
246
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
247
+
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
248
+
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
233
249
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
234
250
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
235
251
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
236
252
github.com/sethvargo/go-envconfig v1.1.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
237
253
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
238
-
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
239
-
github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag=
240
-
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
241
-
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
242
254
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
243
255
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
244
256
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
···
246
258
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
247
259
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
248
260
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
261
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
262
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
249
263
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
250
264
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
251
265
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
266
+
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
252
267
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
268
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
269
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
270
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
271
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
253
272
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
254
273
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
255
274
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
···
257
276
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
258
277
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
259
278
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
260
-
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
261
-
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
262
279
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
263
280
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
264
281
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
···
270
287
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
271
288
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
272
289
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
273
-
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
274
-
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
275
-
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
276
-
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
277
-
go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc=
278
-
go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
290
+
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
291
+
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
292
+
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
293
+
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
294
+
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
295
+
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
279
296
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
280
297
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
281
298
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
···
292
309
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
293
310
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
294
311
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
295
-
golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
296
312
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
297
313
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
298
314
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
299
315
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
300
316
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
301
-
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
302
-
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
303
-
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
304
-
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
305
-
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
317
+
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
306
318
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
307
319
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
308
-
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
309
-
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
320
+
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=
321
+
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
310
322
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
311
323
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
312
324
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
313
325
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
314
326
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
315
327
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
316
-
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
328
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
317
329
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
318
330
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
319
331
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
···
321
333
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
322
334
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
323
335
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
324
-
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
325
336
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
326
-
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
327
-
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
328
337
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
329
-
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
338
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
330
339
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
331
340
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
332
341
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
334
343
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
335
344
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
336
345
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
346
+
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
337
347
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
338
348
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
339
-
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
340
-
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
341
349
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
342
350
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
343
-
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
344
-
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
345
351
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
346
-
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
347
352
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
348
353
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
349
354
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
350
-
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
355
+
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
351
356
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
352
-
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
353
357
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
354
-
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
355
-
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
356
-
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
357
-
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
358
358
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
359
359
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
360
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
361
+
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
360
362
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
361
363
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
362
364
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
363
365
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
364
-
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
365
-
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
366
366
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
367
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
368
+
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
367
369
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
368
370
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
369
371
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
370
372
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
371
-
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
372
373
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
373
-
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
374
374
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
375
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
376
+
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
375
377
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
376
378
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
377
-
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
378
-
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
379
+
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
380
+
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
379
381
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
380
382
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
381
383
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
388
390
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
389
391
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
390
392
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
391
-
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
393
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
392
394
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
393
395
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
394
396
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
···
400
402
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
401
403
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
402
404
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
403
-
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
404
405
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
405
406
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
406
407
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
···
411
412
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
412
413
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
413
414
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
414
-
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
415
415
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
416
416
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
417
417
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
418
418
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
419
419
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
420
-
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
420
+
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
421
+
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+336
knotclient/signer.go
+336
knotclient/signer.go
···
1
+
package knotclient
2
+
3
+
import (
4
+
"bytes"
5
+
"crypto/hmac"
6
+
"crypto/sha256"
7
+
"encoding/hex"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"log"
12
+
"net/http"
13
+
"net/url"
14
+
"time"
15
+
16
+
"tangled.sh/tangled.sh/core/types"
17
+
)
18
+
19
+
type SignerTransport struct {
20
+
Secret string
21
+
}
22
+
23
+
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
24
+
timestamp := time.Now().Format(time.RFC3339)
25
+
mac := hmac.New(sha256.New, []byte(s.Secret))
26
+
message := req.Method + req.URL.Path + timestamp
27
+
mac.Write([]byte(message))
28
+
signature := hex.EncodeToString(mac.Sum(nil))
29
+
req.Header.Set("X-Signature", signature)
30
+
req.Header.Set("X-Timestamp", timestamp)
31
+
return http.DefaultTransport.RoundTrip(req)
32
+
}
33
+
34
+
type SignedClient struct {
35
+
Secret string
36
+
Url *url.URL
37
+
client *http.Client
38
+
}
39
+
40
+
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
41
+
client := &http.Client{
42
+
Timeout: 5 * time.Second,
43
+
Transport: SignerTransport{
44
+
Secret: secret,
45
+
},
46
+
}
47
+
48
+
scheme := "https"
49
+
if dev {
50
+
scheme = "http"
51
+
}
52
+
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
53
+
if err != nil {
54
+
return nil, err
55
+
}
56
+
57
+
signedClient := &SignedClient{
58
+
Secret: secret,
59
+
client: client,
60
+
Url: url,
61
+
}
62
+
63
+
return signedClient, nil
64
+
}
65
+
66
+
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
67
+
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
68
+
}
69
+
70
+
func (s *SignedClient) Init(did string) (*http.Response, error) {
71
+
const (
72
+
Method = "POST"
73
+
Endpoint = "/init"
74
+
)
75
+
76
+
body, _ := json.Marshal(map[string]any{
77
+
"did": did,
78
+
})
79
+
80
+
req, err := s.newRequest(Method, Endpoint, body)
81
+
if err != nil {
82
+
return nil, err
83
+
}
84
+
85
+
return s.client.Do(req)
86
+
}
87
+
88
+
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
89
+
const (
90
+
Method = "PUT"
91
+
Endpoint = "/repo/new"
92
+
)
93
+
94
+
body, _ := json.Marshal(map[string]any{
95
+
"did": did,
96
+
"name": repoName,
97
+
"default_branch": defaultBranch,
98
+
})
99
+
100
+
req, err := s.newRequest(Method, Endpoint, body)
101
+
if err != nil {
102
+
return nil, err
103
+
}
104
+
105
+
return s.client.Do(req)
106
+
}
107
+
108
+
func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
109
+
const (
110
+
Method = "GET"
111
+
)
112
+
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
113
+
114
+
req, err := s.newRequest(Method, endpoint, nil)
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
119
+
resp, err := s.client.Do(req)
120
+
if err != nil {
121
+
return nil, err
122
+
}
123
+
124
+
var result types.RepoLanguageResponse
125
+
if resp.StatusCode != http.StatusOK {
126
+
log.Println("failed to calculate languages", resp.Status)
127
+
return &types.RepoLanguageResponse{}, nil
128
+
}
129
+
130
+
body, err := io.ReadAll(resp.Body)
131
+
if err != nil {
132
+
return nil, err
133
+
}
134
+
135
+
err = json.Unmarshal(body, &result)
136
+
if err != nil {
137
+
return nil, err
138
+
}
139
+
140
+
return &result, nil
141
+
}
142
+
143
+
func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) {
144
+
const (
145
+
Method = "GET"
146
+
)
147
+
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
148
+
149
+
body, _ := json.Marshal(map[string]any{
150
+
"did": ownerDid,
151
+
"source": source,
152
+
"name": name,
153
+
"hiddenref": hiddenRef,
154
+
})
155
+
156
+
req, err := s.newRequest(Method, endpoint, body)
157
+
if err != nil {
158
+
return nil, err
159
+
}
160
+
161
+
return s.client.Do(req)
162
+
}
163
+
164
+
func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) {
165
+
const (
166
+
Method = "POST"
167
+
)
168
+
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
169
+
170
+
body, _ := json.Marshal(map[string]any{
171
+
"did": ownerDid,
172
+
"source": source,
173
+
"name": name,
174
+
})
175
+
176
+
req, err := s.newRequest(Method, endpoint, body)
177
+
if err != nil {
178
+
return nil, err
179
+
}
180
+
181
+
return s.client.Do(req)
182
+
}
183
+
184
+
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
185
+
const (
186
+
Method = "POST"
187
+
Endpoint = "/repo/fork"
188
+
)
189
+
190
+
body, _ := json.Marshal(map[string]any{
191
+
"did": ownerDid,
192
+
"source": source,
193
+
"name": name,
194
+
})
195
+
196
+
req, err := s.newRequest(Method, Endpoint, body)
197
+
if err != nil {
198
+
return nil, err
199
+
}
200
+
201
+
return s.client.Do(req)
202
+
}
203
+
204
+
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
205
+
const (
206
+
Method = "DELETE"
207
+
Endpoint = "/repo"
208
+
)
209
+
210
+
body, _ := json.Marshal(map[string]any{
211
+
"did": did,
212
+
"name": repoName,
213
+
})
214
+
215
+
req, err := s.newRequest(Method, Endpoint, body)
216
+
if err != nil {
217
+
return nil, err
218
+
}
219
+
220
+
return s.client.Do(req)
221
+
}
222
+
223
+
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
224
+
const (
225
+
Method = "PUT"
226
+
Endpoint = "/member/add"
227
+
)
228
+
229
+
body, _ := json.Marshal(map[string]any{
230
+
"did": did,
231
+
})
232
+
233
+
req, err := s.newRequest(Method, Endpoint, body)
234
+
if err != nil {
235
+
return nil, err
236
+
}
237
+
238
+
return s.client.Do(req)
239
+
}
240
+
241
+
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
242
+
const (
243
+
Method = "PUT"
244
+
)
245
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
246
+
247
+
body, _ := json.Marshal(map[string]any{
248
+
"branch": branch,
249
+
})
250
+
251
+
req, err := s.newRequest(Method, endpoint, body)
252
+
if err != nil {
253
+
return nil, err
254
+
}
255
+
256
+
return s.client.Do(req)
257
+
}
258
+
259
+
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
260
+
const (
261
+
Method = "POST"
262
+
)
263
+
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
264
+
265
+
body, _ := json.Marshal(map[string]any{
266
+
"did": memberDid,
267
+
})
268
+
269
+
req, err := s.newRequest(Method, endpoint, body)
270
+
if err != nil {
271
+
return nil, err
272
+
}
273
+
274
+
return s.client.Do(req)
275
+
}
276
+
277
+
func (s *SignedClient) Merge(
278
+
patch []byte,
279
+
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
280
+
) (*http.Response, error) {
281
+
const (
282
+
Method = "POST"
283
+
)
284
+
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
285
+
286
+
mr := types.MergeRequest{
287
+
Branch: branch,
288
+
CommitMessage: commitMessage,
289
+
CommitBody: commitBody,
290
+
AuthorName: authorName,
291
+
AuthorEmail: authorEmail,
292
+
Patch: string(patch),
293
+
}
294
+
295
+
body, _ := json.Marshal(mr)
296
+
297
+
req, err := s.newRequest(Method, endpoint, body)
298
+
if err != nil {
299
+
return nil, err
300
+
}
301
+
302
+
return s.client.Do(req)
303
+
}
304
+
305
+
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
306
+
const (
307
+
Method = "POST"
308
+
)
309
+
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
310
+
311
+
body, _ := json.Marshal(map[string]any{
312
+
"patch": string(patch),
313
+
"branch": branch,
314
+
})
315
+
316
+
req, err := s.newRequest(Method, endpoint, body)
317
+
if err != nil {
318
+
return nil, err
319
+
}
320
+
321
+
return s.client.Do(req)
322
+
}
323
+
324
+
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
325
+
const (
326
+
Method = "POST"
327
+
)
328
+
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
329
+
330
+
req, err := s.newRequest(Method, endpoint, nil)
331
+
if err != nil {
332
+
return nil, err
333
+
}
334
+
335
+
return s.client.Do(req)
336
+
}
+250
knotclient/unsigned.go
+250
knotclient/unsigned.go
···
1
+
package knotclient
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/json"
6
+
"fmt"
7
+
"io"
8
+
"log"
9
+
"net/http"
10
+
"net/url"
11
+
"strconv"
12
+
"time"
13
+
14
+
"tangled.sh/tangled.sh/core/types"
15
+
)
16
+
17
+
type UnsignedClient struct {
18
+
Url *url.URL
19
+
client *http.Client
20
+
}
21
+
22
+
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
23
+
client := &http.Client{
24
+
Timeout: 5 * time.Second,
25
+
}
26
+
27
+
scheme := "https"
28
+
if dev {
29
+
scheme = "http"
30
+
}
31
+
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
32
+
if err != nil {
33
+
return nil, err
34
+
}
35
+
36
+
unsignedClient := &UnsignedClient{
37
+
client: client,
38
+
Url: url,
39
+
}
40
+
41
+
return unsignedClient, nil
42
+
}
43
+
44
+
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
45
+
reqUrl := us.Url.JoinPath(endpoint)
46
+
47
+
// add query parameters
48
+
if query != nil {
49
+
reqUrl.RawQuery = query.Encode()
50
+
}
51
+
52
+
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
53
+
}
54
+
55
+
func do[T any](us *UnsignedClient, req *http.Request) (*T, error) {
56
+
resp, err := us.client.Do(req)
57
+
if err != nil {
58
+
return nil, err
59
+
}
60
+
defer resp.Body.Close()
61
+
62
+
body, err := io.ReadAll(resp.Body)
63
+
if err != nil {
64
+
log.Printf("Error reading response body: %v", err)
65
+
return nil, err
66
+
}
67
+
68
+
var result T
69
+
err = json.Unmarshal(body, &result)
70
+
if err != nil {
71
+
log.Printf("Error unmarshalling response body: %v", err)
72
+
return nil, err
73
+
}
74
+
75
+
return &result, nil
76
+
}
77
+
78
+
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) {
79
+
const (
80
+
Method = "GET"
81
+
)
82
+
83
+
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
84
+
if ref == "" {
85
+
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
86
+
}
87
+
88
+
req, err := us.newRequest(Method, endpoint, nil, nil)
89
+
if err != nil {
90
+
return nil, err
91
+
}
92
+
93
+
return do[types.RepoIndexResponse](us, req)
94
+
}
95
+
96
+
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) {
97
+
const (
98
+
Method = "GET"
99
+
)
100
+
101
+
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
102
+
103
+
query := url.Values{}
104
+
query.Add("page", strconv.Itoa(page))
105
+
query.Add("per_page", strconv.Itoa(60))
106
+
107
+
req, err := us.newRequest(Method, endpoint, query, nil)
108
+
if err != nil {
109
+
return nil, err
110
+
}
111
+
112
+
return do[types.RepoLogResponse](us, req)
113
+
}
114
+
115
+
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) {
116
+
const (
117
+
Method = "GET"
118
+
)
119
+
120
+
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
121
+
122
+
req, err := us.newRequest(Method, endpoint, nil, nil)
123
+
if err != nil {
124
+
return nil, err
125
+
}
126
+
127
+
return do[types.RepoBranchesResponse](us, req)
128
+
}
129
+
130
+
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
131
+
const (
132
+
Method = "GET"
133
+
)
134
+
135
+
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
136
+
137
+
req, err := us.newRequest(Method, endpoint, nil, nil)
138
+
if err != nil {
139
+
return nil, err
140
+
}
141
+
142
+
return do[types.RepoTagsResponse](us, req)
143
+
}
144
+
145
+
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) {
146
+
const (
147
+
Method = "GET"
148
+
)
149
+
150
+
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
151
+
152
+
req, err := us.newRequest(Method, endpoint, nil, nil)
153
+
if err != nil {
154
+
return nil, err
155
+
}
156
+
157
+
return do[types.RepoBranchResponse](us, req)
158
+
}
159
+
160
+
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
161
+
const (
162
+
Method = "GET"
163
+
)
164
+
165
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
166
+
167
+
req, err := us.newRequest(Method, endpoint, nil, nil)
168
+
if err != nil {
169
+
return nil, err
170
+
}
171
+
172
+
resp, err := us.client.Do(req)
173
+
if err != nil {
174
+
return nil, err
175
+
}
176
+
defer resp.Body.Close()
177
+
178
+
var defaultBranch types.RepoDefaultBranchResponse
179
+
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
180
+
return nil, err
181
+
}
182
+
183
+
return &defaultBranch, nil
184
+
}
185
+
186
+
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
187
+
const (
188
+
Method = "GET"
189
+
Endpoint = "/capabilities"
190
+
)
191
+
192
+
req, err := us.newRequest(Method, Endpoint, nil, nil)
193
+
if err != nil {
194
+
return nil, err
195
+
}
196
+
197
+
resp, err := us.client.Do(req)
198
+
if err != nil {
199
+
return nil, err
200
+
}
201
+
defer resp.Body.Close()
202
+
203
+
var capabilities types.Capabilities
204
+
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
205
+
return nil, err
206
+
}
207
+
208
+
return &capabilities, nil
209
+
}
210
+
211
+
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
212
+
const (
213
+
Method = "GET"
214
+
)
215
+
216
+
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
217
+
218
+
req, err := us.newRequest(Method, endpoint, nil, nil)
219
+
if err != nil {
220
+
return nil, fmt.Errorf("Failed to create request.")
221
+
}
222
+
223
+
compareResp, err := us.client.Do(req)
224
+
if err != nil {
225
+
return nil, fmt.Errorf("Failed to create request.")
226
+
}
227
+
defer compareResp.Body.Close()
228
+
229
+
switch compareResp.StatusCode {
230
+
case 404:
231
+
case 400:
232
+
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
233
+
}
234
+
235
+
respBody, err := io.ReadAll(compareResp.Body)
236
+
if err != nil {
237
+
log.Println("failed to compare across branches")
238
+
return nil, fmt.Errorf("Failed to compare branches.")
239
+
}
240
+
defer compareResp.Body.Close()
241
+
242
+
var formatPatchResponse types.RepoFormatPatchResponse
243
+
err = json.Unmarshal(respBody, &formatPatchResponse)
244
+
if err != nil {
245
+
log.Println("failed to unmarshal format-patch response", err)
246
+
return nil, fmt.Errorf("failed to compare branches.")
247
+
}
248
+
249
+
return &formatPatchResponse, nil
250
+
}
+85
-6
knotserver/git/diff.go
+85
-6
knotserver/git/diff.go
···
6
6
"log"
7
7
"os"
8
8
"os/exec"
9
+
"slices"
9
10
"strings"
10
11
11
12
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
126
127
127
128
// FormatPatch generates a git-format-patch output between two commits,
128
129
// and returns the raw format-patch series, a parsed FormatPatch and an error.
129
-
func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) {
130
+
func (g *GitRepo) formatSinglePatch(base, commit2 plumbing.Hash, extraArgs ...string) (string, *patchutil.FormatPatch, error) {
130
131
var stdout bytes.Buffer
131
-
cmd := exec.Command(
132
-
"git",
132
+
133
+
args := []string{
133
134
"-C",
134
135
g.path,
135
136
"format-patch",
136
-
fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()),
137
+
fmt.Sprintf("%s..%s", base.String(), commit2.String()),
137
138
"--stdout",
138
-
)
139
+
}
140
+
args = append(args, extraArgs...)
141
+
142
+
cmd := exec.Command("git", args...)
139
143
cmd.Stdout = &stdout
140
144
cmd.Stderr = os.Stderr
141
145
err := cmd.Run()
···
148
152
return "", nil, err
149
153
}
150
154
151
-
return stdout.String(), formatPatch, nil
155
+
if len(formatPatch) > 1 {
156
+
return "", nil, fmt.Errorf("running format-patch on single commit produced more than on patch")
157
+
}
158
+
159
+
return stdout.String(), &formatPatch[0], nil
152
160
}
153
161
154
162
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
···
187
195
188
196
return commit, nil
189
197
}
198
+
199
+
func (g *GitRepo) commitsBetween(newCommit, oldCommit *object.Commit) ([]*object.Commit, error) {
200
+
var commits []*object.Commit
201
+
current := newCommit
202
+
203
+
for {
204
+
if current.Hash == oldCommit.Hash {
205
+
break
206
+
}
207
+
208
+
commits = append(commits, current)
209
+
210
+
if len(current.ParentHashes) == 0 {
211
+
return nil, fmt.Errorf("old commit %s not found in history of new commit %s", oldCommit.Hash, newCommit.Hash)
212
+
}
213
+
214
+
parent, err := current.Parents().Next()
215
+
if err != nil {
216
+
return nil, fmt.Errorf("error getting parent: %w", err)
217
+
}
218
+
219
+
current = parent
220
+
}
221
+
222
+
return commits, nil
223
+
}
224
+
225
+
func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) {
226
+
// get list of commits between commir2 and base
227
+
commits, err := g.commitsBetween(commit2, base)
228
+
if err != nil {
229
+
return "", nil, fmt.Errorf("failed to get commits: %w", err)
230
+
}
231
+
232
+
// reverse the list so we start from the oldest one and go up to the most recent one
233
+
slices.Reverse(commits)
234
+
235
+
var allPatchesContent strings.Builder
236
+
var allPatches []patchutil.FormatPatch
237
+
238
+
for _, commit := range commits {
239
+
changeId := ""
240
+
if val, ok := commit.ExtraHeaders["change-id"]; ok {
241
+
changeId = string(val)
242
+
}
243
+
244
+
var parentHash plumbing.Hash
245
+
if len(commit.ParentHashes) > 0 {
246
+
parentHash = commit.ParentHashes[0]
247
+
} else {
248
+
parentHash = plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") // git empty tree hash
249
+
}
250
+
251
+
var additionalArgs []string
252
+
if changeId != "" {
253
+
additionalArgs = append(additionalArgs, "--add-header", fmt.Sprintf("Change-Id: %s", changeId))
254
+
}
255
+
256
+
stdout, patch, err := g.formatSinglePatch(parentHash, commit.Hash, additionalArgs...)
257
+
if err != nil {
258
+
return "", nil, fmt.Errorf("failed to format patch for commit %s: %w", commit.Hash.String(), err)
259
+
}
260
+
261
+
allPatchesContent.WriteString(stdout)
262
+
allPatchesContent.WriteString("\n")
263
+
264
+
allPatches = append(allPatches, *patch)
265
+
}
266
+
267
+
return allPatchesContent.String(), allPatches, nil
268
+
}
+16
knotserver/git/fork.go
+16
knotserver/git/fork.go
···
27
27
return nil
28
28
}
29
29
30
+
func (g *GitRepo) Sync(branch string) error {
31
+
fetchOpts := &git.FetchOptions{
32
+
RefSpecs: []config.RefSpec{
33
+
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)),
34
+
},
35
+
}
36
+
37
+
err := g.r.Fetch(fetchOpts)
38
+
if errors.Is(git.NoErrAlreadyUpToDate, err) {
39
+
return nil
40
+
} else if err != nil {
41
+
return fmt.Errorf("failed to fetch origin branch: %s: %w", branch, err)
42
+
}
43
+
return nil
44
+
}
45
+
30
46
// TrackHiddenRemoteRef tracks a hidden remote in the repository. For example,
31
47
// if the feature branch on the fork (forkRef) is feature-1, and the remoteRef,
32
48
// i.e. the branch we want to merge into, is main, this will result in a refspec:
+36
-3
knotserver/git/git.go
+36
-3
knotserver/git/git.go
···
169
169
return c, nil
170
170
}
171
171
172
+
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
173
+
buf := []byte{}
174
+
175
+
c, err := g.r.CommitObject(g.h)
176
+
if err != nil {
177
+
return nil, fmt.Errorf("commit object: %w", err)
178
+
}
179
+
180
+
tree, err := c.Tree()
181
+
if err != nil {
182
+
return nil, fmt.Errorf("file tree: %w", err)
183
+
}
184
+
185
+
file, err := tree.File(path)
186
+
if err != nil {
187
+
return nil, err
188
+
}
189
+
190
+
isbin, _ := file.IsBinary()
191
+
192
+
if !isbin {
193
+
reader, err := file.Reader()
194
+
if err != nil {
195
+
return nil, err
196
+
}
197
+
bufReader := io.LimitReader(reader, cap)
198
+
_, err = bufReader.Read(buf)
199
+
if err != nil {
200
+
return nil, err
201
+
}
202
+
return buf, nil
203
+
} else {
204
+
return nil, ErrBinaryFile
205
+
}
206
+
}
207
+
172
208
func (g *GitRepo) FileContent(path string) (string, error) {
173
209
c, err := g.r.CommitObject(g.h)
174
210
if err != nil {
···
261
297
branches := []types.Branch{}
262
298
263
299
defaultBranch, err := g.FindMainBranch()
264
-
if err != nil {
265
-
return nil, fmt.Errorf("getting default branch", "error", err.Error())
266
-
}
267
300
268
301
_ = bi.ForEach(func(ref *plumbing.Reference) error {
269
302
b := types.Branch{}
+64
-71
knotserver/git/service/service.go
+64
-71
knotserver/git/service/service.go
···
15
15
// Mostly from charmbracelet/soft-serve and sosedoff/gitkit.
16
16
17
17
type ServiceCommand struct {
18
-
Dir string
19
-
Stdin io.Reader
20
-
Stdout http.ResponseWriter
18
+
GitProtocol string
19
+
Dir string
20
+
Stdin io.Reader
21
+
Stdout http.ResponseWriter
21
22
}
22
23
23
-
func (c *ServiceCommand) InfoRefs() error {
24
-
cmd := exec.Command("git", []string{
25
-
"upload-pack",
26
-
"--stateless-rpc",
27
-
"--advertise-refs",
28
-
".",
29
-
}...)
30
-
31
-
cmd.Dir = c.Dir
24
+
func (c *ServiceCommand) RunService(cmd *exec.Cmd) error {
32
25
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
33
-
stdoutPipe, _ := cmd.StdoutPipe()
34
-
cmd.Stderr = cmd.Stdout
35
-
36
-
if err := cmd.Start(); err != nil {
37
-
log.Printf("git: failed to start git-upload-pack (info/refs): %s", err)
38
-
return err
39
-
}
40
-
41
-
if err := packLine(c.Stdout, "# service=git-upload-pack\n"); err != nil {
42
-
log.Printf("git: failed to write pack line: %s", err)
43
-
return err
44
-
}
45
-
46
-
if err := packFlush(c.Stdout); err != nil {
47
-
log.Printf("git: failed to flush pack: %s", err)
48
-
return err
49
-
}
50
-
51
-
buf := bytes.Buffer{}
52
-
if _, err := io.Copy(&buf, stdoutPipe); err != nil {
53
-
log.Printf("git: failed to copy stdout to tmp buffer: %s", err)
54
-
return err
55
-
}
56
-
57
-
if err := cmd.Wait(); err != nil {
58
-
out := strings.Builder{}
59
-
_, _ = io.Copy(&out, &buf)
60
-
log.Printf("git: failed to run git-upload-pack; err: %s; output: %s", err, out.String())
61
-
return err
62
-
}
63
-
64
-
if _, err := io.Copy(c.Stdout, &buf); err != nil {
65
-
log.Printf("git: failed to copy stdout: %s", err)
66
-
}
67
-
68
-
return nil
69
-
}
26
+
cmd.Dir = c.Dir
27
+
cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol))
70
28
71
-
func (c *ServiceCommand) UploadPack() error {
72
29
var stderr bytes.Buffer
73
-
74
-
cmd := exec.Command("git", "-c", "uploadpack.allowFilter=true",
75
-
"upload-pack", "--stateless-rpc", ".")
76
-
cmd.Dir = c.Dir
77
-
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
30
+
cmd.Stderr = &stderr
78
31
79
32
stdoutPipe, err := cmd.StdoutPipe()
80
33
if err != nil {
81
34
return fmt.Errorf("failed to create stdout pipe: %w", err)
82
35
}
83
36
84
-
cmd.Stderr = &stderr
85
-
86
37
stdinPipe, err := cmd.StdinPipe()
87
38
if err != nil {
88
39
return fmt.Errorf("failed to create stdin pipe: %w", err)
89
40
}
90
41
91
42
if err := cmd.Start(); err != nil {
92
-
return fmt.Errorf("failed to start git-upload-pack: %w", err)
43
+
return fmt.Errorf("failed to start '%s': %w", cmd.String(), err)
93
44
}
94
45
95
46
var wg sync.WaitGroup
96
47
97
-
wg.Add(1)
98
-
go func() {
99
-
defer wg.Done()
100
-
defer stdinPipe.Close()
101
-
io.Copy(stdinPipe, c.Stdin)
102
-
}()
48
+
if c.Stdin != nil {
49
+
wg.Add(1)
50
+
go func() {
51
+
defer wg.Done()
52
+
defer stdinPipe.Close()
53
+
io.Copy(stdinPipe, c.Stdin)
54
+
}()
55
+
}
103
56
104
-
wg.Add(1)
105
-
go func() {
106
-
defer wg.Done()
107
-
io.Copy(newWriteFlusher(c.Stdout), stdoutPipe)
108
-
stdoutPipe.Close()
109
-
}()
57
+
if c.Stdout != nil {
58
+
wg.Add(1)
59
+
go func() {
60
+
defer wg.Done()
61
+
io.Copy(newWriteFlusher(c.Stdout), stdoutPipe)
62
+
stdoutPipe.Close()
63
+
}()
64
+
}
110
65
111
66
wg.Wait()
112
67
113
68
if err := cmd.Wait(); err != nil {
114
-
return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String())
69
+
return fmt.Errorf("'%s' failed: %w, stderr: %s", cmd.String(), err, stderr.String())
115
70
}
116
71
117
72
return nil
73
+
}
74
+
75
+
func (c *ServiceCommand) InfoRefs() error {
76
+
cmd := exec.Command("git", []string{
77
+
"upload-pack",
78
+
"--stateless-rpc",
79
+
"--http-backend-info-refs",
80
+
".",
81
+
}...)
82
+
83
+
if !strings.Contains(c.GitProtocol, "version=2") {
84
+
if err := packLine(c.Stdout, "# service=git-upload-pack\n"); err != nil {
85
+
log.Printf("git: failed to write pack line: %s", err)
86
+
return err
87
+
}
88
+
89
+
if err := packFlush(c.Stdout); err != nil {
90
+
log.Printf("git: failed to flush pack: %s", err)
91
+
return err
92
+
}
93
+
}
94
+
95
+
return c.RunService(cmd)
96
+
}
97
+
98
+
func (c *ServiceCommand) UploadPack() error {
99
+
cmd := exec.Command("git", []string{
100
+
"-c", "uploadpack.allowFilter=true",
101
+
"upload-pack",
102
+
"--stateless-rpc",
103
+
".",
104
+
}...)
105
+
106
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
107
+
cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol))
108
+
cmd.Dir = c.Dir
109
+
110
+
return c.RunService(cmd)
118
111
}
119
112
120
113
func packLine(w io.Writer, s string) error {
+88
-14
knotserver/git.go
+88
-14
knotserver/git.go
···
2
2
3
3
import (
4
4
"compress/gzip"
5
+
"fmt"
5
6
"io"
6
7
"net/http"
7
8
"path/filepath"
9
+
"strings"
8
10
9
11
securejoin "github.com/cyphar/filepath-securejoin"
10
12
"github.com/go-chi/chi/v5"
···
14
16
func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
17
did := chi.URLParam(r, "did")
16
18
name := chi.URLParam(r, "name")
17
-
repo, _ := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
19
+
repoName, err := securejoin.SecureJoin(did, name)
20
+
if err != nil {
21
+
gitError(w, "repository not found", http.StatusNotFound)
22
+
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
23
+
return
24
+
}
18
25
19
-
w.Header().Set("content-type", "application/x-git-upload-pack-advertisement")
20
-
w.WriteHeader(http.StatusOK)
26
+
repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName)
27
+
if err != nil {
28
+
gitError(w, "repository not found", http.StatusNotFound)
29
+
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
30
+
return
31
+
}
21
32
22
33
cmd := service.ServiceCommand{
23
-
Dir: repo,
24
-
Stdout: w,
34
+
GitProtocol: r.Header.Get("Git-Protocol"),
35
+
Dir: repoPath,
36
+
Stdout: w,
25
37
}
26
38
27
-
if err := cmd.InfoRefs(); err != nil {
28
-
writeError(w, err.Error(), 500)
29
-
d.l.Error("git: failed to execute git-upload-pack (info/refs)", "handler", "InfoRefs", "error", err)
30
-
return
39
+
serviceName := r.URL.Query().Get("service")
40
+
switch serviceName {
41
+
case "git-upload-pack":
42
+
w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
43
+
w.Header().Set("Connection", "Keep-Alive")
44
+
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
45
+
w.WriteHeader(http.StatusOK)
46
+
47
+
if err := cmd.InfoRefs(); err != nil {
48
+
gitError(w, err.Error(), http.StatusInternalServerError)
49
+
d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
50
+
return
51
+
}
52
+
case "git-receive-pack":
53
+
d.RejectPush(w, r, name)
54
+
default:
55
+
gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
31
56
}
32
57
}
33
58
···
36
61
name := chi.URLParam(r, "name")
37
62
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
38
63
if err != nil {
39
-
writeError(w, err.Error(), 500)
64
+
gitError(w, err.Error(), http.StatusInternalServerError)
40
65
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
41
66
return
67
+
}
68
+
69
+
const expectedContentType = "application/x-git-upload-pack-request"
70
+
contentType := r.Header.Get("Content-Type")
71
+
if contentType != expectedContentType {
72
+
gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
42
73
}
43
74
44
75
var bodyReader io.ReadCloser = r.Body
45
76
if r.Header.Get("Content-Encoding") == "gzip" {
46
77
gzipReader, err := gzip.NewReader(r.Body)
47
78
if err != nil {
48
-
writeError(w, err.Error(), 500)
79
+
gitError(w, err.Error(), http.StatusInternalServerError)
49
80
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
50
81
return
51
82
}
···
55
86
56
87
w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
57
88
w.Header().Set("Connection", "Keep-Alive")
89
+
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
58
90
59
91
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
60
92
61
93
cmd := service.ServiceCommand{
62
-
Dir: repo,
63
-
Stdout: w,
64
-
Stdin: bodyReader,
94
+
GitProtocol: r.Header.Get("Git-Protocol"),
95
+
Dir: repo,
96
+
Stdout: w,
97
+
Stdin: bodyReader,
65
98
}
66
99
67
100
w.WriteHeader(http.StatusOK)
···
71
104
return
72
105
}
73
106
}
107
+
108
+
func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) {
109
+
did := chi.URLParam(r, "did")
110
+
name := chi.URLParam(r, "name")
111
+
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
112
+
if err != nil {
113
+
gitError(w, err.Error(), http.StatusForbidden)
114
+
d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
115
+
return
116
+
}
117
+
118
+
d.RejectPush(w, r, name)
119
+
}
120
+
121
+
func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
122
+
// A text/plain response will cause git to print each line of the body
123
+
// prefixed with "remote: ".
124
+
w.Header().Set("content-type", "text/plain; charset=UTF-8")
125
+
w.WriteHeader(http.StatusForbidden)
126
+
127
+
fmt.Fprintf(w, "Pushes are only supported over SSH.")
128
+
129
+
// If the appview gave us the repository owner's handle we can attempt to
130
+
// construct the correct ssh url.
131
+
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
+
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
133
+
hostname := d.c.Server.Hostname
134
+
if strings.Contains(hostname, ":") {
135
+
hostname = strings.Split(hostname, ":")[0]
136
+
}
137
+
138
+
fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
139
+
}
140
+
fmt.Fprintf(w, "\n\n")
141
+
}
142
+
143
+
func gitError(w http.ResponseWriter, msg string, status int) {
144
+
w.Header().Set("content-type", "text/plain; charset=UTF-8")
145
+
w.WriteHeader(status)
146
+
fmt.Fprintf(w, "%s\n", msg)
147
+
}
+12
-1
knotserver/handler.go
+12
-1
knotserver/handler.go
···
80
80
r.Post("/add", h.AddRepoCollaborator)
81
81
})
82
82
83
+
r.Route("/languages", func(r chi.Router) {
84
+
r.With(h.VerifySignature)
85
+
r.Get("/", h.RepoLanguages)
86
+
r.Get("/{ref}", h.RepoLanguages)
87
+
})
88
+
83
89
r.Get("/", h.RepoIndex)
84
90
r.Get("/info/refs", h.InfoRefs)
85
91
r.Post("/git-upload-pack", h.UploadPack)
92
+
r.Post("/git-receive-pack", h.ReceivePack)
86
93
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
87
94
88
95
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
···
126
133
r.Use(h.VerifySignature)
127
134
r.Put("/new", h.NewRepo)
128
135
r.Delete("/", h.RemoveRepo)
129
-
r.Post("/fork", h.RepoFork)
136
+
r.Route("/fork", func(r chi.Router) {
137
+
r.Post("/", h.RepoFork)
138
+
r.Post("/sync/{branch}", h.RepoForkSync)
139
+
r.Get("/sync/{branch}", h.RepoForkAheadBehind)
140
+
})
130
141
})
131
142
132
143
r.Route("/member", func(r chi.Router) {
+253
-11
knotserver/routes.go
+253
-11
knotserver/routes.go
···
12
12
"net/http"
13
13
"net/url"
14
14
"os"
15
+
"path"
15
16
"path/filepath"
16
17
"strconv"
17
18
"strings"
···
19
20
securejoin "github.com/cyphar/filepath-securejoin"
20
21
"github.com/gliderlabs/ssh"
21
22
"github.com/go-chi/chi/v5"
23
+
"github.com/go-enry/go-enry/v2"
22
24
gogit "github.com/go-git/go-git/v5"
23
25
"github.com/go-git/go-git/v5/plumbing"
24
26
"github.com/go-git/go-git/v5/plumbing/object"
···
61
63
62
64
gr, err := git.Open(path, ref)
63
65
if err != nil {
66
+
plain, err2 := git.PlainOpen(path)
67
+
if err2 != nil {
68
+
l.Error("opening repo", "error", err2.Error())
69
+
notFound(w)
70
+
return
71
+
}
72
+
branches, _ := plain.Branches()
73
+
64
74
log.Println(err)
75
+
65
76
if errors.Is(err, plumbing.ErrReferenceNotFound) {
66
77
resp := types.RepoIndexResponse{
67
-
IsEmpty: true,
78
+
IsEmpty: true,
79
+
Branches: branches,
68
80
}
69
81
writeJSON(w, resp)
70
82
return
···
218
230
mimeType := http.DetectContentType(contents)
219
231
220
232
// exception for svg
221
-
if strings.HasPrefix(mimeType, "text/xml") && filepath.Ext(treePath) == ".svg" {
233
+
if filepath.Ext(treePath) == ".svg" {
222
234
mimeType = "image/svg+xml"
223
235
}
224
236
···
461
473
462
474
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
463
475
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
464
-
l := h.l.With("handler", "Branches")
465
476
466
-
gr, err := git.Open(path, "")
477
+
gr, err := git.PlainOpen(path)
467
478
if err != nil {
468
479
notFound(w)
469
480
return
470
481
}
471
482
472
-
branches, err := gr.Branches()
473
-
if err != nil {
474
-
l.Error("getting branches", "error", err.Error())
475
-
writeError(w, err.Error(), http.StatusInternalServerError)
476
-
return
477
-
}
483
+
branches, _ := gr.Branches()
478
484
479
485
resp := types.RepoBranchesResponse{
480
486
Branches: branches,
···
600
606
name := data.Name
601
607
defaultBranch := data.DefaultBranch
602
608
609
+
if err := validateRepoName(name); err != nil {
610
+
l.Error("creating repo", "error", err.Error())
611
+
writeError(w, err.Error(), http.StatusBadRequest)
612
+
return
613
+
}
614
+
603
615
relativeRepoPath := filepath.Join(did, name)
604
616
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
605
617
err := git.InitBare(repoPath, defaultBranch)
···
625
637
w.WriteHeader(http.StatusNoContent)
626
638
}
627
639
640
+
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
641
+
l := h.l.With("handler", "RepoForkSync")
642
+
643
+
data := struct {
644
+
Did string `json:"did"`
645
+
Source string `json:"source"`
646
+
Name string `json:"name,omitempty"`
647
+
HiddenRef string `json:"hiddenref"`
648
+
}{}
649
+
650
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
651
+
writeError(w, "invalid request body", http.StatusBadRequest)
652
+
return
653
+
}
654
+
655
+
did := data.Did
656
+
source := data.Source
657
+
658
+
if did == "" || source == "" {
659
+
l.Error("invalid request body, empty did or name")
660
+
w.WriteHeader(http.StatusBadRequest)
661
+
return
662
+
}
663
+
664
+
var name string
665
+
if data.Name != "" {
666
+
name = data.Name
667
+
} else {
668
+
name = filepath.Base(source)
669
+
}
670
+
671
+
branch := chi.URLParam(r, "branch")
672
+
branch, _ = url.PathUnescape(branch)
673
+
674
+
relativeRepoPath := filepath.Join(did, name)
675
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
676
+
677
+
gr, err := git.PlainOpen(repoPath)
678
+
if err != nil {
679
+
log.Println(err)
680
+
notFound(w)
681
+
return
682
+
}
683
+
684
+
forkCommit, err := gr.ResolveRevision(branch)
685
+
if err != nil {
686
+
l.Error("error resolving ref revision", "msg", err.Error())
687
+
writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
688
+
return
689
+
}
690
+
691
+
sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
692
+
if err != nil {
693
+
l.Error("error resolving hidden ref revision", "msg", err.Error())
694
+
writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
695
+
return
696
+
}
697
+
698
+
status := types.UpToDate
699
+
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
700
+
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
701
+
if err != nil {
702
+
log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
703
+
return
704
+
}
705
+
706
+
if isAncestor {
707
+
status = types.FastForwardable
708
+
} else {
709
+
status = types.Conflict
710
+
}
711
+
}
712
+
713
+
w.Header().Set("Content-Type", "application/json")
714
+
json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
715
+
}
716
+
717
+
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
718
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
719
+
ref := chi.URLParam(r, "ref")
720
+
ref, _ = url.PathUnescape(ref)
721
+
722
+
l := h.l.With("handler", "RepoLanguages")
723
+
724
+
gr, err := git.Open(path, ref)
725
+
if err != nil {
726
+
l.Error("opening repo", "error", err.Error())
727
+
notFound(w)
728
+
return
729
+
}
730
+
731
+
languageFileCount := make(map[string]int)
732
+
733
+
err = recurseEntireTree(gr, func(absPath string) {
734
+
lang, safe := enry.GetLanguageByExtension(absPath)
735
+
if len(lang) == 0 || !safe {
736
+
content, _ := gr.FileContentN(absPath, 1024)
737
+
if !safe {
738
+
lang = enry.GetLanguage(absPath, content)
739
+
} else {
740
+
lang, _ = enry.GetLanguageByContent(absPath, content)
741
+
if len(lang) == 0 {
742
+
return
743
+
}
744
+
}
745
+
}
746
+
747
+
v, ok := languageFileCount[lang]
748
+
if ok {
749
+
languageFileCount[lang] = v + 1
750
+
} else {
751
+
languageFileCount[lang] = 1
752
+
}
753
+
}, "")
754
+
if err != nil {
755
+
l.Error("failed to recurse file tree", "error", err.Error())
756
+
writeError(w, err.Error(), http.StatusNoContent)
757
+
return
758
+
}
759
+
760
+
resp := types.RepoLanguageResponse{Languages: languageFileCount}
761
+
762
+
writeJSON(w, resp)
763
+
return
764
+
}
765
+
766
+
func recurseEntireTree(git *git.GitRepo, callback func(absPath string), filePath string) error {
767
+
files, err := git.FileTree(filePath)
768
+
if err != nil {
769
+
log.Println(err)
770
+
return err
771
+
}
772
+
773
+
for _, file := range files {
774
+
absPath := path.Join(filePath, file.Name)
775
+
if !file.IsFile {
776
+
return recurseEntireTree(git, callback, absPath)
777
+
}
778
+
callback(absPath)
779
+
}
780
+
781
+
return nil
782
+
}
783
+
784
+
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
785
+
l := h.l.With("handler", "RepoForkSync")
786
+
787
+
data := struct {
788
+
Did string `json:"did"`
789
+
Source string `json:"source"`
790
+
Name string `json:"name,omitempty"`
791
+
}{}
792
+
793
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
794
+
writeError(w, "invalid request body", http.StatusBadRequest)
795
+
return
796
+
}
797
+
798
+
did := data.Did
799
+
source := data.Source
800
+
801
+
if did == "" || source == "" {
802
+
l.Error("invalid request body, empty did or name")
803
+
w.WriteHeader(http.StatusBadRequest)
804
+
return
805
+
}
806
+
807
+
var name string
808
+
if data.Name != "" {
809
+
name = data.Name
810
+
} else {
811
+
name = filepath.Base(source)
812
+
}
813
+
814
+
branch := chi.URLParam(r, "branch")
815
+
branch, _ = url.PathUnescape(branch)
816
+
817
+
relativeRepoPath := filepath.Join(did, name)
818
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
819
+
820
+
gr, err := git.PlainOpen(repoPath)
821
+
if err != nil {
822
+
log.Println(err)
823
+
notFound(w)
824
+
return
825
+
}
826
+
827
+
err = gr.Sync(branch)
828
+
if err != nil {
829
+
l.Error("error syncing repo fork", "error", err.Error())
830
+
writeError(w, err.Error(), http.StatusInternalServerError)
831
+
return
832
+
}
833
+
834
+
w.WriteHeader(http.StatusNoContent)
835
+
}
836
+
628
837
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
629
838
l := h.l.With("handler", "RepoFork")
630
839
···
866
1075
Rev1: commit1.Hash.String(),
867
1076
Rev2: commit2.Hash.String(),
868
1077
FormatPatch: formatPatch,
1078
+
MergeBase: mergeBase.Hash.String(),
869
1079
Patch: rawPatch,
870
1080
})
871
1081
return
···
1007
1217
return
1008
1218
}
1009
1219
1010
-
gr, err := git.Open(path, "")
1220
+
gr, err := git.PlainOpen(path)
1011
1221
if err != nil {
1012
1222
notFound(w)
1013
1223
return
···
1078
1288
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
1079
1289
w.Write([]byte("ok"))
1080
1290
}
1291
+
1292
+
func validateRepoName(name string) error {
1293
+
// check for path traversal attempts
1294
+
if name == "." || name == ".." ||
1295
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
1296
+
return fmt.Errorf("Repository name contains invalid path characters")
1297
+
}
1298
+
1299
+
// check for sequences that could be used for traversal when normalized
1300
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
1301
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
1302
+
return fmt.Errorf("Repository name contains invalid path sequence")
1303
+
}
1304
+
1305
+
// then continue with character validation
1306
+
for _, char := range name {
1307
+
if !((char >= 'a' && char <= 'z') ||
1308
+
(char >= 'A' && char <= 'Z') ||
1309
+
(char >= '0' && char <= '9') ||
1310
+
char == '-' || char == '_' || char == '.') {
1311
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
1312
+
}
1313
+
}
1314
+
1315
+
// additional check to prevent multiple sequential dots
1316
+
if strings.Contains(name, "..") {
1317
+
return fmt.Errorf("Repository name cannot contain sequential dots")
1318
+
}
1319
+
1320
+
// if all checks pass
1321
+
return nil
1322
+
}
+72
lexicons/actor/profile.json
+72
lexicons/actor/profile.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.actor.profile",
4
+
"defs": {
5
+
"main": {
6
+
"type": "record",
7
+
"description": "A declaration of a Tangled account profile.",
8
+
"key": "literal:self",
9
+
"record": {
10
+
"type": "object",
11
+
"required": [
12
+
"bluesky"
13
+
],
14
+
"properties": {
15
+
"description": {
16
+
"type": "string",
17
+
"description": "Free-form profile description text.",
18
+
"maxGraphemes": 256,
19
+
"maxLength": 2560
20
+
},
21
+
"links": {
22
+
"type": "array",
23
+
"minLength": 0,
24
+
"maxLength": 5,
25
+
"items": {
26
+
"type": "string",
27
+
"description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.",
28
+
"format": "uri"
29
+
}
30
+
},
31
+
"stats": {
32
+
"type": "array",
33
+
"minLength": 0,
34
+
"maxLength": 2,
35
+
"items": {
36
+
"type": "string",
37
+
"description": "Vanity stats.",
38
+
"enum": [
39
+
"merged-pull-request-count",
40
+
"closed-pull-request-count",
41
+
"open-pull-request-count",
42
+
"open-issue-count",
43
+
"closed-issue-count",
44
+
"repository-count"
45
+
]
46
+
}
47
+
},
48
+
"bluesky": {
49
+
"type": "boolean",
50
+
"description": "Include link to this account on Bluesky."
51
+
},
52
+
"location": {
53
+
"type": "string",
54
+
"description": "Free-form location text.",
55
+
"maxGraphemes": 40,
56
+
"maxLength": 400
57
+
},
58
+
"pinnedRepositories": {
59
+
"type": "array",
60
+
"description": "Any ATURI, it is up to appviews to validate these fields.",
61
+
"minLength": 0,
62
+
"maxLength": 6,
63
+
"items": {
64
+
"type": "string",
65
+
"format": "at-uri"
66
+
}
67
+
}
68
+
}
69
+
}
70
+
}
71
+
}
72
+
}
-1
lexicons/publicKey.json
-1
lexicons/publicKey.json
+2
-1
license
+2
-1
license
···
1
1
MIT License
2
2
3
-
Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan
3
+
Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan and
4
+
contributors.
4
5
5
6
Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
of this software and associated documentation files (the "Software"), to deal
+69
patchutil/patchutil.go
+69
patchutil/patchutil.go
···
5
5
"os"
6
6
"os/exec"
7
7
"regexp"
8
+
"slices"
8
9
"strings"
9
10
10
11
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
13
14
type FormatPatch struct {
14
15
Files []*gitdiff.File
15
16
*gitdiff.PatchHeader
17
+
Raw string
18
+
}
19
+
20
+
func (f FormatPatch) ChangeId() (string, error) {
21
+
if vals, ok := f.RawHeaders["Change-Id"]; ok && len(vals) == 1 {
22
+
return vals[0], nil
23
+
}
24
+
return "", fmt.Errorf("no change-id found")
16
25
}
17
26
18
27
func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
···
34
43
result = append(result, FormatPatch{
35
44
Files: files,
36
45
PatchHeader: header,
46
+
Raw: patch,
37
47
})
38
48
}
39
49
···
194
204
195
205
return string(output), nil
196
206
}
207
+
208
+
// are two patches identical
209
+
func Equal(a, b []*gitdiff.File) bool {
210
+
return slices.EqualFunc(a, b, func(x, y *gitdiff.File) bool {
211
+
// same pointer
212
+
if x == y {
213
+
return true
214
+
}
215
+
if x == nil || y == nil {
216
+
return x == y
217
+
}
218
+
219
+
// compare file metadata
220
+
if x.OldName != y.OldName || x.NewName != y.NewName {
221
+
return false
222
+
}
223
+
if x.OldMode != y.OldMode || x.NewMode != y.NewMode {
224
+
return false
225
+
}
226
+
if x.IsNew != y.IsNew || x.IsDelete != y.IsDelete || x.IsCopy != y.IsCopy || x.IsRename != y.IsRename {
227
+
return false
228
+
}
229
+
230
+
if len(x.TextFragments) != len(y.TextFragments) {
231
+
return false
232
+
}
233
+
234
+
for i, xFrag := range x.TextFragments {
235
+
yFrag := y.TextFragments[i]
236
+
237
+
// Compare fragment headers
238
+
if xFrag.OldPosition != yFrag.OldPosition || xFrag.OldLines != yFrag.OldLines ||
239
+
xFrag.NewPosition != yFrag.NewPosition || xFrag.NewLines != yFrag.NewLines {
240
+
return false
241
+
}
242
+
243
+
// Compare fragment changes
244
+
if len(xFrag.Lines) != len(yFrag.Lines) {
245
+
return false
246
+
}
247
+
248
+
for j, xLine := range xFrag.Lines {
249
+
yLine := yFrag.Lines[j]
250
+
if xLine.Op != yLine.Op || xLine.Line != yLine.Line {
251
+
return false
252
+
}
253
+
}
254
+
}
255
+
256
+
return true
257
+
})
258
+
}
259
+
260
+
// sort patch files in alphabetical order
261
+
func SortPatch(patch []*gitdiff.File) {
262
+
slices.SortFunc(patch, func(a, b *gitdiff.File) int {
263
+
return strings.Compare(bestName(a), bestName(b))
264
+
})
265
+
}
+6
-5
readme.md
+6
-5
readme.md
···
4
4
[Tangled](https://tangled.sh)—a code collaboration platform built
5
5
on the [AT Protocol](https://atproto.com).
6
6
7
-
Read the introduction to Tangled [here](https://blog.tangled.sh/intro).
7
+
Read the introduction to Tangled [here](https://blog.tangled.sh/intro). Join the
8
+
[Discord](https://chat.tangled.sh) or IRC at [#tangled on
9
+
libera.chat](https://web.libera.chat/#tangled).
8
10
9
11
## docs
10
12
11
-
* [knot hosting
12
-
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md)
13
-
* [contributing
14
-
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/contributing.md)—**read this before opening a PR!**
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)
15
16
16
17
## security
17
18
+5
scripts/generate-jwks.sh
+5
scripts/generate-jwks.sh
+28
-4
types/repo.go
+28
-4
types/repo.go
···
37
37
Rev1 string `json:"rev1,omitempty"`
38
38
Rev2 string `json:"rev2,omitempty"`
39
39
FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"`
40
+
MergeBase string `json:"merge_base,omitempty"`
40
41
Patch string `json:"patch,omitempty"`
41
42
}
42
43
···
49
50
}
50
51
51
52
type TagReference struct {
52
-
Reference `json:"ref,omitempty"`
53
-
Tag *object.Tag `json:"tag,omitempty"`
54
-
Message string `json:"message,omitempty"`
53
+
Reference
54
+
Tag *object.Tag `json:"tag,omitempty"`
55
+
Message string `json:"message,omitempty"`
55
56
}
56
57
57
58
type Reference struct {
···
74
75
}
75
76
76
77
type RepoBranchResponse struct {
77
-
Branch Branch `json:"branch,omitempty"`
78
+
Branch Branch
78
79
}
79
80
80
81
type RepoDefaultBranchResponse struct {
···
90
91
Lines int `json:"lines,omitempty"`
91
92
SizeHint uint64 `json:"size_hint,omitempty"`
92
93
}
94
+
95
+
type ForkStatus int
96
+
97
+
const (
98
+
UpToDate ForkStatus = 0
99
+
FastForwardable = 1
100
+
Conflict = 2
101
+
MissingBranch = 3
102
+
)
103
+
104
+
type ForkInfo struct {
105
+
IsFork bool
106
+
Status ForkStatus
107
+
}
108
+
109
+
type AncestorCheckResponse struct {
110
+
Status ForkStatus `json:"status"`
111
+
}
112
+
113
+
type RepoLanguageResponse struct {
114
+
// Language: Percentage
115
+
Languages map[string]int `json:"languages"`
116
+
}