forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+7476 -2411
.air
api
appview
camo
cmd
appview
genjwks
docker
docs
knotclient
knotserver
lexicons
patchutil
scripts
types
+1 -1
.air/appview.toml
··· 1 1 [build] 2 2 cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go" 3 - bin = ".bin/app" 3 + bin = ";set -o allexport && source .env && set +o allexport; .bin/app" 4 4 root = "." 5 5 6 6 exclude_regex = [".*_templ.go"]
+4 -1
.gitignore
··· 9 9 out/ 10 10 ./camo/node_modules/* 11 11 ./avatar/node_modules/* 12 - 12 + patches 13 + *.qcow2 14 + .DS_Store 15 + .env
+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
··· 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
··· 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
··· 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
··· 9 9 SessionRefreshJwt = "refreshJwt" 10 10 SessionExpiry = "expiry" 11 11 SessionAuthenticated = "authenticated" 12 + 13 + SessionDpopPrivateJwk = "dpopPrivateJwk" 14 + SessionDpopAuthServerNonce = "dpopAuthServerNonce" 12 15 )
-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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 63 63 SourceHandle string 64 64 Ref string 65 65 DisableFork bool 66 + CurrentDir string 66 67 } 67 68 68 69 // each tab on a repo could have some metadata:
-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
··· 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
··· 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/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t"> 2 + <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 3 <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 4 <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 5 </div>
+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
··· 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
··· 1 1 {{ define "title" }}{{ .Path }} at {{ .Ref }} &middot; {{ .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 &middot; %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
··· 1 1 {{ define "title" }} 2 - branches ยท {{ .RepoInfo.FullName }} 2 + branches &middot; {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "extrameta" }} 6 + {{ $title := printf "branches &middot; %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
··· 1 1 {{ define "title" }} commit {{ .Diff.Commit.This }} &middot; {{ .RepoInfo.FullName }} {{ end }} 2 2 3 + {{ define "extrameta" }} 4 + {{ $title := printf "commit %s &middot; %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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 1 {{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 + 4 + {{ define "extrameta" }} 5 + {{ $title := printf "%s &middot; issue #%d &middot; %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
··· 1 1 {{ define "title" }}issues &middot; {{ .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
··· 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
··· 1 1 {{ define "title" }}commits &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 + {{ define "extrameta" }} 4 + {{ $title := printf "commits &middot; %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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 1 {{ define "title" }} 2 - interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 2 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + 6 + {{ define "extrameta" }} 7 + {{ $title := printf "interdiff of %d and %d &middot; %s &middot; pull #%d &middot; %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
··· 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
··· 2 2 patch of {{ .Pull.Title }} &middot; round #{{ .Round }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 3 {{ end }} 4 4 5 + 6 + {{ define "extrameta" }} 7 + {{ $title := printf "patch of %s &middot; pull #%d &middot; %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
··· 2 2 {{ .Pull.Title }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 3 {{ end }} 4 4 5 + {{ define "extrameta" }} 6 + {{ $title := printf "%s &middot; pull #%d &middot; %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
··· 1 1 {{ define "title" }}pulls &middot; {{ .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
··· 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>
+7
appview/pages/templates/repo/tags.html
··· 2 2 tags ยท {{ .RepoInfo.FullName }} 3 3 {{ end }} 4 4 5 + {{ define "extrameta" }} 6 + {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.sh/%s/tags" .RepoInfo.FullName }} 8 + 9 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 + {{ end }} 11 + 5 12 {{ define "repoContent" }} 6 13 <section> 7 14 <h2 class="mb-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">tags</h2>
+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 &middot; %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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 &middot; 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 4 4 "github.com/bluesky-social/indigo/atproto/syntax" 5 5 ) 6 6 7 - var c *syntax.TIDClock = syntax.NewTIDClock(0) 7 + var c syntax.TIDClock = syntax.NewTIDClock(0) 8 8 9 9 func TID() string { 10 10 return c.Next().String()
+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
··· 41 41 "image/x-rgb", 42 42 "image/x-xbitmap", 43 43 "image/x-xpixmap", 44 - "image/x-xwindowdump" 44 + "image/x-xwindowdump", 45 + "video/mp4" 45 46 ]
+2 -2
cmd/appview/main.go
··· 26 26 log.Fatal(err) 27 27 } 28 28 29 - log.Println("starting server on", c.ListenAddr) 30 - log.Println(http.ListenAndServe(c.ListenAddr, state.Router())) 29 + log.Println("starting server on", c.Core.ListenAddr) 30 + log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 31 31 }
+1
cmd/gen.go
··· 27 27 tangled.RepoPullStatus{}, 28 28 tangled.RepoPullComment{}, 29 29 tangled.RepoArtifact{}, 30 + tangled.ActorProfile{}, 30 31 ); err != nil { 31 32 panic(err) 32 33 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 22 22 }, 23 23 "name": { 24 24 "type": "string", 25 - "format": "string", 26 25 "description": "human-readable name for this key" 27 26 }, 28 27 "createdAt": {
+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
··· 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
··· 4 4 [Tangled](https://tangled.sh)&mdash;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)&mdash;**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
··· 1 + #! /usr/bin/env bash 2 + 3 + set -e 4 + 5 + go run ./cmd/genjwks/
+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 + }
+1
types/tree.go
··· 8 8 9 9 // A nicer git tree representation. 10 10 type NiceTree struct { 11 + // Relative path 11 12 Name string `json:"name"` 12 13 Mode string `json:"mode"` 13 14 Size int64 `json:"size"`