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

Compare changes

Choose any two refs to compare.

+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 - }
+36 -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 + 9 39 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"` 40 + Core CoreConfig `env:",prefix=TANGLED_"` 41 + Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 42 + Resend ResendConfig `env:",prefix=TANGLED_RESEND_"` 43 + Camo CamoConfig `env:",prefix=TANGLED_CAMO_"` 44 + Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 45 + OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 20 46 } 21 47 22 48 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
+99
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 ··· 348 431 349 432 return nil 350 433 } 434 + 435 + type filter struct { 436 + key string 437 + arg any 438 + } 439 + 440 + func Filter(key string, arg any) filter { 441 + return filter{ 442 + key: key, 443 + arg: arg, 444 + } 445 + } 446 + 447 + func (f filter) Condition() string { 448 + return fmt.Sprintf("%s = ?", f.key) 449 + }
+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 + }
+1 -11
appview/db/pulls.go
··· 235 235 } 236 236 237 237 func NewPull(tx *sql.Tx, pull *Pull) error { 238 - defer tx.Rollback() 239 - 240 238 _, err := tx.Exec(` 241 239 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 242 240 values (?, 1) ··· 291 289 insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 292 290 values (?, ?, ?, ?, ?) 293 291 `, 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 292 + return err 303 293 } 304 294 305 295 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
+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
+104 -5
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 191 err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) 181 192 } 182 193 ··· 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.Filter("did", did), db.Filter("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 + }
+489
appview/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 + "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 + }
+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 + }
+309
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 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/knotclient" 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/rbac" 23 + ) 24 + 25 + const ( 26 + oauthScope = "atproto transition:generic" 27 + ) 28 + 29 + type OAuthHandler struct { 30 + Config *appview.Config 31 + Pages *pages.Pages 32 + Resolver *appview.Resolver 33 + Db *db.DB 34 + Store *sessions.CookieStore 35 + OAuth *oauth.OAuth 36 + Enforcer *rbac.Enforcer 37 + } 38 + 39 + func (o *OAuthHandler) Router() http.Handler { 40 + r := chi.NewRouter() 41 + 42 + r.Get("/login", o.login) 43 + r.Post("/login", o.login) 44 + 45 + r.With(middleware.AuthMiddleware(o.OAuth)).Post("/logout", o.logout) 46 + 47 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 48 + r.Get("/oauth/jwks.json", o.jwks) 49 + r.Get("/oauth/callback", o.callback) 50 + return r 51 + } 52 + 53 + func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 54 + w.Header().Set("Content-Type", "application/json") 55 + w.WriteHeader(http.StatusOK) 56 + json.NewEncoder(w).Encode(o.OAuth.ClientMetadata()) 57 + } 58 + 59 + func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 60 + jwks := o.Config.OAuth.Jwks 61 + pubKey, err := pubKeyFromJwk(jwks) 62 + if err != nil { 63 + log.Printf("error parsing public key: %v", err) 64 + http.Error(w, err.Error(), http.StatusInternalServerError) 65 + return 66 + } 67 + 68 + response := helpers.CreateJwksResponseObject(pubKey) 69 + 70 + w.Header().Set("Content-Type", "application/json") 71 + w.WriteHeader(http.StatusOK) 72 + json.NewEncoder(w).Encode(response) 73 + } 74 + 75 + func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 76 + switch r.Method { 77 + case http.MethodGet: 78 + o.Pages.Login(w, pages.LoginParams{}) 79 + case http.MethodPost: 80 + handle := strings.TrimPrefix(r.FormValue("handle"), "@") 81 + 82 + resolved, err := o.Resolver.ResolveIdent(r.Context(), handle) 83 + if err != nil { 84 + log.Println("failed to resolve handle:", err) 85 + o.Pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 86 + return 87 + } 88 + self := o.OAuth.ClientMetadata() 89 + oauthClient, err := client.NewClient( 90 + self.ClientID, 91 + o.Config.OAuth.Jwks, 92 + self.RedirectURIs[0], 93 + ) 94 + 95 + if err != nil { 96 + log.Println("failed to create oauth client:", err) 97 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 98 + return 99 + } 100 + 101 + authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 102 + if err != nil { 103 + log.Println("failed to resolve auth server:", err) 104 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 105 + return 106 + } 107 + 108 + authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 109 + if err != nil { 110 + log.Println("failed to fetch auth server metadata:", err) 111 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 112 + return 113 + } 114 + 115 + dpopKey, err := helpers.GenerateKey(nil) 116 + if err != nil { 117 + log.Println("failed to generate dpop key:", err) 118 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 119 + return 120 + } 121 + 122 + dpopKeyJson, err := json.Marshal(dpopKey) 123 + if err != nil { 124 + log.Println("failed to marshal dpop key:", err) 125 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 126 + return 127 + } 128 + 129 + parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 130 + if err != nil { 131 + log.Println("failed to send par auth request:", err) 132 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 133 + return 134 + } 135 + 136 + err = db.SaveOAuthRequest(o.Db, db.OAuthRequest{ 137 + Did: resolved.DID.String(), 138 + PdsUrl: resolved.PDSEndpoint(), 139 + Handle: handle, 140 + AuthserverIss: authMeta.Issuer, 141 + PkceVerifier: parResp.PkceVerifier, 142 + DpopAuthserverNonce: parResp.DpopAuthserverNonce, 143 + DpopPrivateJwk: string(dpopKeyJson), 144 + State: parResp.State, 145 + }) 146 + if err != nil { 147 + log.Println("failed to save oauth request:", err) 148 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 149 + return 150 + } 151 + 152 + u, _ := url.Parse(authMeta.AuthorizationEndpoint) 153 + query := url.Values{} 154 + query.Add("client_id", self.ClientID) 155 + query.Add("request_uri", parResp.RequestUri) 156 + u.RawQuery = query.Encode() 157 + o.Pages.HxRedirect(w, u.String()) 158 + } 159 + } 160 + 161 + func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 162 + state := r.FormValue("state") 163 + 164 + oauthRequest, err := db.GetOAuthRequestByState(o.Db, state) 165 + if err != nil { 166 + log.Println("failed to get oauth request:", err) 167 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 + return 169 + } 170 + 171 + defer func() { 172 + err := db.DeleteOAuthRequestByState(o.Db, state) 173 + if err != nil { 174 + log.Println("failed to delete oauth request for state:", state, err) 175 + } 176 + }() 177 + 178 + error := r.FormValue("error") 179 + errorDescription := r.FormValue("error_description") 180 + if error != "" || errorDescription != "" { 181 + log.Printf("error: %s, %s", error, errorDescription) 182 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 183 + return 184 + } 185 + 186 + code := r.FormValue("code") 187 + if code == "" { 188 + log.Println("missing code for state: ", state) 189 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 190 + return 191 + } 192 + 193 + iss := r.FormValue("iss") 194 + if iss == "" { 195 + log.Println("missing iss for state: ", state) 196 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 197 + return 198 + } 199 + 200 + self := o.OAuth.ClientMetadata() 201 + 202 + oauthClient, err := client.NewClient( 203 + self.ClientID, 204 + o.Config.OAuth.Jwks, 205 + self.RedirectURIs[0], 206 + ) 207 + 208 + if err != nil { 209 + log.Println("failed to create oauth client:", err) 210 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 211 + return 212 + } 213 + 214 + jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 215 + if err != nil { 216 + log.Println("failed to parse jwk:", err) 217 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 218 + return 219 + } 220 + 221 + tokenResp, err := oauthClient.InitialTokenRequest( 222 + r.Context(), 223 + code, 224 + oauthRequest.AuthserverIss, 225 + oauthRequest.PkceVerifier, 226 + oauthRequest.DpopAuthserverNonce, 227 + jwk, 228 + ) 229 + if err != nil { 230 + log.Println("failed to get token:", err) 231 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 232 + return 233 + } 234 + 235 + if tokenResp.Scope != oauthScope { 236 + log.Println("scope doesn't match:", tokenResp.Scope) 237 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 238 + return 239 + } 240 + 241 + err = o.OAuth.SaveSession(w, r, oauthRequest, tokenResp) 242 + if err != nil { 243 + log.Println("failed to save session:", err) 244 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 245 + return 246 + } 247 + 248 + log.Println("session saved successfully") 249 + go o.addToDefaultKnot(oauthRequest.Did) 250 + 251 + http.Redirect(w, r, "/", http.StatusFound) 252 + } 253 + 254 + func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 255 + err := o.OAuth.ClearSession(r, w) 256 + if err != nil { 257 + log.Println("failed to clear session:", err) 258 + http.Redirect(w, r, "/", http.StatusFound) 259 + return 260 + } 261 + 262 + log.Println("session cleared successfully") 263 + http.Redirect(w, r, "/", http.StatusFound) 264 + } 265 + 266 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 267 + k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 268 + if err != nil { 269 + return nil, err 270 + } 271 + pubKey, err := k.PublicKey() 272 + if err != nil { 273 + return nil, err 274 + } 275 + return pubKey, nil 276 + } 277 + 278 + func (o *OAuthHandler) addToDefaultKnot(did string) { 279 + defaultKnot := "knot1.tangled.sh" 280 + 281 + log.Printf("adding %s to default knot", did) 282 + err := o.Enforcer.AddMember(defaultKnot, did) 283 + if err != nil { 284 + log.Println("failed to add user to knot1.tangled.sh: ", err) 285 + return 286 + } 287 + err = o.Enforcer.E.SavePolicy() 288 + if err != nil { 289 + log.Println("failed to add user to knot1.tangled.sh: ", err) 290 + return 291 + } 292 + 293 + secret, err := db.GetRegistrationKey(o.Db, defaultKnot) 294 + if err != nil { 295 + log.Println("failed to get registration key for knot1.tangled.sh") 296 + return 297 + } 298 + signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.Config.Core.Dev) 299 + resp, err := signedClient.AddMember(did) 300 + if err != nil { 301 + log.Println("failed to add user to knot1.tangled.sh: ", err) 302 + return 303 + } 304 + 305 + if resp.StatusCode != http.StatusNoContent { 306 + log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 307 + return 308 + } 309 + }
+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
+2
appview/pages/markup/markdown.go
··· 10 10 "github.com/yuin/goldmark/ast" 11 11 "github.com/yuin/goldmark/extension" 12 12 "github.com/yuin/goldmark/parser" 13 + "github.com/yuin/goldmark/renderer/html" 13 14 "github.com/yuin/goldmark/text" 14 15 "github.com/yuin/goldmark/util" 15 16 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" ··· 41 42 goldmark.WithParserOptions( 42 43 parser.WithAutoHeadingID(), 43 44 ), 45 + goldmark.WithRendererOptions(html.WithUnsafe()), 44 46 ) 45 47 46 48 if rctx != nil {
+82 -43
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 406 + LoggedInUser *oauth.User 368 407 RepoInfo repoinfo.RepoInfo 369 408 Active string 370 409 TagMap map[string][]string ··· 405 444 } 406 445 407 446 type RepoLogParams struct { 408 - LoggedInUser *auth.User 447 + LoggedInUser *oauth.User 409 448 RepoInfo repoinfo.RepoInfo 410 449 TagMap map[string][]string 411 450 types.RepoLogResponse ··· 419 458 } 420 459 421 460 type RepoCommitParams struct { 422 - LoggedInUser *auth.User 461 + LoggedInUser *oauth.User 423 462 RepoInfo repoinfo.RepoInfo 424 463 Active string 425 464 EmailToDidOrHandle map[string]string ··· 433 472 } 434 473 435 474 type RepoTreeParams struct { 436 - LoggedInUser *auth.User 475 + LoggedInUser *oauth.User 437 476 RepoInfo repoinfo.RepoInfo 438 477 Active string 439 478 BreadCrumbs [][]string ··· 469 508 } 470 509 471 510 type RepoBranchesParams struct { 472 - LoggedInUser *auth.User 511 + LoggedInUser *oauth.User 473 512 RepoInfo repoinfo.RepoInfo 474 513 Active string 475 514 types.RepoBranchesResponse ··· 481 520 } 482 521 483 522 type RepoTagsParams struct { 484 - LoggedInUser *auth.User 523 + LoggedInUser *oauth.User 485 524 RepoInfo repoinfo.RepoInfo 486 525 Active string 487 526 types.RepoTagsResponse ··· 495 534 } 496 535 497 536 type RepoArtifactParams struct { 498 - LoggedInUser *auth.User 537 + LoggedInUser *oauth.User 499 538 RepoInfo repoinfo.RepoInfo 500 539 Artifact db.Artifact 501 540 } ··· 505 544 } 506 545 507 546 type RepoBlobParams struct { 508 - LoggedInUser *auth.User 547 + LoggedInUser *oauth.User 509 548 RepoInfo repoinfo.RepoInfo 510 549 Active string 511 550 BreadCrumbs [][]string ··· 523 562 case markup.FormatMarkdown: 524 563 p.rctx.RepoInfo = params.RepoInfo 525 564 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 526 - params.RenderedContents = template.HTML(p.rctx.RenderMarkdown(params.Contents)) 565 + params.RenderedContents = template.HTML(bluemonday.UGCPolicy().Sanitize(p.rctx.RenderMarkdown(params.Contents))) 527 566 } 528 567 } 529 568 ··· 567 606 } 568 607 569 608 type RepoSettingsParams struct { 570 - LoggedInUser *auth.User 609 + LoggedInUser *oauth.User 571 610 RepoInfo repoinfo.RepoInfo 572 611 Collaborators []Collaborator 573 612 Active string ··· 583 622 } 584 623 585 624 type RepoIssuesParams struct { 586 - LoggedInUser *auth.User 625 + LoggedInUser *oauth.User 587 626 RepoInfo repoinfo.RepoInfo 588 627 Active string 589 628 Issues []db.Issue ··· 598 637 } 599 638 600 639 type RepoSingleIssueParams struct { 601 - LoggedInUser *auth.User 640 + LoggedInUser *oauth.User 602 641 RepoInfo repoinfo.RepoInfo 603 642 Active string 604 643 Issue db.Issue ··· 620 659 } 621 660 622 661 type RepoNewIssueParams struct { 623 - LoggedInUser *auth.User 662 + LoggedInUser *oauth.User 624 663 RepoInfo repoinfo.RepoInfo 625 664 Active string 626 665 } ··· 631 670 } 632 671 633 672 type EditIssueCommentParams struct { 634 - LoggedInUser *auth.User 673 + LoggedInUser *oauth.User 635 674 RepoInfo repoinfo.RepoInfo 636 675 Issue *db.Issue 637 676 Comment *db.Comment ··· 642 681 } 643 682 644 683 type SingleIssueCommentParams struct { 645 - LoggedInUser *auth.User 684 + LoggedInUser *oauth.User 646 685 DidHandleMap map[string]string 647 686 RepoInfo repoinfo.RepoInfo 648 687 Issue *db.Issue ··· 654 693 } 655 694 656 695 type RepoNewPullParams struct { 657 - LoggedInUser *auth.User 696 + LoggedInUser *oauth.User 658 697 RepoInfo repoinfo.RepoInfo 659 698 Branches []types.Branch 660 699 Active string ··· 666 705 } 667 706 668 707 type RepoPullsParams struct { 669 - LoggedInUser *auth.User 708 + LoggedInUser *oauth.User 670 709 RepoInfo repoinfo.RepoInfo 671 710 Pulls []*db.Pull 672 711 Active string ··· 698 737 } 699 738 700 739 type RepoSinglePullParams struct { 701 - LoggedInUser *auth.User 740 + LoggedInUser *oauth.User 702 741 RepoInfo repoinfo.RepoInfo 703 742 Active string 704 743 DidHandleMap map[string]string ··· 713 752 } 714 753 715 754 type RepoPullPatchParams struct { 716 - LoggedInUser *auth.User 755 + LoggedInUser *oauth.User 717 756 DidHandleMap map[string]string 718 757 RepoInfo repoinfo.RepoInfo 719 758 Pull *db.Pull ··· 728 767 } 729 768 730 769 type RepoPullInterdiffParams struct { 731 - LoggedInUser *auth.User 770 + LoggedInUser *oauth.User 732 771 DidHandleMap map[string]string 733 772 RepoInfo repoinfo.RepoInfo 734 773 Pull *db.Pull ··· 778 817 } 779 818 780 819 type PullResubmitParams struct { 781 - LoggedInUser *auth.User 820 + LoggedInUser *oauth.User 782 821 RepoInfo repoinfo.RepoInfo 783 822 Pull *db.Pull 784 823 SubmissionId int ··· 789 828 } 790 829 791 830 type PullActionsParams struct { 792 - LoggedInUser *auth.User 831 + LoggedInUser *oauth.User 793 832 RepoInfo repoinfo.RepoInfo 794 833 Pull *db.Pull 795 834 RoundNumber int ··· 802 841 } 803 842 804 843 type PullNewCommentParams struct { 805 - LoggedInUser *auth.User 844 + LoggedInUser *oauth.User 806 845 RepoInfo repoinfo.RepoInfo 807 846 Pull *db.Pull 808 847 RoundNumber int
+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/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 }}
+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>
+3 -3
appview/pages/templates/repo/index.html
··· 103 103 class="{{ $linkstyle }}" 104 104 > 105 105 <div class="flex items-center gap-2"> 106 - {{ i "folder" "w-3 h-3 fill-current" }} 106 + {{ i "folder" "size-4 fill-current" }} 107 107 {{ .Name }} 108 108 </div> 109 109 </a> ··· 125 125 class="{{ $linkstyle }}" 126 126 > 127 127 <div class="flex items-center gap-2"> 128 - {{ i "file" "w-3 h-3" }}{{ .Name }} 128 + {{ i "file" "size-4" }}{{ .Name }} 129 129 </div> 130 130 </a> 131 131 ··· 320 320 {{ define "repoAfter" }} 321 321 {{- if .HTMLReadme }} 322 322 <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 }} 323 + 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 324 prose dark:prose-invert dark:[&_pre]:bg-gray-900 325 325 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 326 326 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+26 -17
appview/pages/templates/repo/issues/issues.html
··· 1 1 {{ define "title" }}issues &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ 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> 4 + <div class="flex justify-between items-center gap-4"> 5 + <div class="flex gap-4"> 6 + <a 7 + href="?state=open" 8 + class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 9 + > 10 + {{ i "circle-dot" "w-4 h-4" }} 11 + <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 12 + </a> 13 + <a 14 + href="?state=closed" 15 + class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 + > 17 + {{ i "ban" "w-4 h-4" }} 18 + <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 19 + </a> 20 + </div> 21 + <a 22 + href="/{{ .RepoInfo.FullName }}/issues/new" 23 + class="btn text-sm flex items-center justify-center gap-2 no-underline hover:no-underline" 24 + > 25 + {{ i "circle-plus" "w-4 h-4" }} 26 + <span>new</span> 27 + </a> 28 + </div> 29 + <div class="error" id="issues"></div> 21 30 {{ end }} 22 31 23 32 {{ define "repoAfter" }}
+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>
+14 -9
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 17 17 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 18 18 hx-target="#actions-{{$roundNumber}}" 19 19 hx-swap="outerHtml" 20 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"> 20 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 21 21 {{ i "message-square-plus" "w-4 h-4" }} 22 22 <span>comment</span> 23 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 23 24 </button> 24 25 {{ if and $isPushAllowed $isOpen $isLastRound }} 25 26 {{ $disabled := "" }} ··· 30 31 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 31 32 hx-swap="none" 32 33 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 }}> 34 + class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 34 35 {{ i "git-merge" "w-4 h-4" }} 35 - <span>merge</span> 36 + <span>merge</span> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 38 </button> 37 39 {{ end }} 38 40 ··· 51 53 {{ end }} 52 54 53 55 hx-disabled-elt="#resubmitBtn" 54 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }} 56 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 55 57 56 58 {{ if $disabled }} 57 59 title="Update this branch to resubmit this pull request" ··· 59 61 title="Resubmit this pull request" 60 62 {{ end }} 61 63 > 62 - {{ i "rotate-ccw" "w-4 h-4" }} 64 + {{ i "rotate-ccw" "w-4 h-4" }} 63 65 <span>resubmit</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 64 67 </button> 65 68 {{ end }} 66 69 ··· 68 71 <button 69 72 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 70 73 hx-swap="none" 71 - class="btn p-2 flex items-center gap-2"> 72 - {{ i "ban" "w-4 h-4" }} 74 + class="btn p-2 flex items-center gap-2 group"> 75 + {{ i "ban" "w-4 h-4" }} 73 76 <span>close</span> 77 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 78 </button> 75 79 {{ end }} 76 80 ··· 78 82 <button 79 83 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 80 84 hx-swap="none" 81 - class="btn p-2 flex items-center gap-2"> 82 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 85 + class="btn p-2 flex items-center gap-2 group"> 86 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 83 87 <span>reopen</span> 88 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 84 89 </button> 85 90 {{ end }} 86 91 </div>
+9 -11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 42 </span> 43 43 </span> 44 44 {{ 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 - 45 + from 54 46 <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 }} 47 + {{ if .Pull.IsForkBased }} 48 + {{ if .Pull.PullSource.Repo }} 49 + <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 50 + {{- else -}} 51 + <span class="italic">[deleted fork]</span> 52 + {{- end -}} 53 + {{- end -}} 54 + {{- .Pull.PullSource.Branch -}} 56 55 </span> 57 - </span> 58 56 {{ end }} 59 57 </span> 60 58 </div>
+1 -1
appview/pages/templates/repo/pulls/new.html
··· 18 18 > 19 19 <option disabled selected>target branch</option> 20 20 {{ range .Branches }} 21 - <option value="{{ .Reference.Name }}" class="py-1"> 21 + <option value="{{ .Reference.Name }}" class="py-1" {{if .IsDefault}}selected{{end}}> 22 22 {{ .Reference.Name }} 23 23 </option> 24 24 {{ end }}
+8 -4
appview/pages/templates/repo/pulls/pull.html
··· 51 51 </span> 52 52 </div> 53 53 54 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 54 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 55 55 hx-boost="true" 56 56 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 57 - {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span> 57 + {{ i "file-diff" "w-4 h-4" }} 58 + <span class="hidden md:inline">diff</span> 59 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 60 </a> 59 61 {{ if not (eq .RoundNumber 0) }} 60 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 62 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 61 63 hx-boost="true" 62 64 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 63 - {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span> 65 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 66 + <span class="hidden md:inline">interdiff</span> 67 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 64 68 </a> 65 69 <span id="interdiff-error-{{.RoundNumber}}"></span> 66 70 {{ end }}
+32 -29
appview/pages/templates/repo/pulls/pulls.html
··· 2 2 3 3 {{ define "repoContent" }} 4 4 <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" 5 + <div class="flex gap-4"> 6 + <a 7 + href="?state=open" 8 + class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 9 + > 10 + {{ i "git-pull-request" "w-4 h-4" }} 11 + <span>{{ .RepoInfo.Stats.PullCount.Open }} open</span> 12 + </a> 13 + <a 14 + href="?state=merged" 15 + class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 + > 17 + {{ i "git-merge" "w-4 h-4" }} 18 + <span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span> 19 + </a> 20 + <a 21 + href="?state=closed" 22 + class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 10 23 > 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> 24 + {{ i "ban" "w-4 h-4" }} 25 + <span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span> 26 + </a> 27 + </div> 23 28 <a 24 29 href="/{{ .RepoInfo.FullName }}/pulls/new" 25 30 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" ··· 79 84 </span> 80 85 </span> 81 86 {{ 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> 87 + from 88 + <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"> 89 + {{ if .IsForkBased }} 90 + {{ if .PullSource.Repo }} 91 + <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 92 + {{- else -}} 93 + <span class="italic">[deleted fork]</span> 94 + {{- end -}} 95 + {{- end -}} 96 + {{- .PullSource.Branch -}} 94 97 </span> 95 98 {{ end }} 96 99 <span class="before:content-['ยท']">
+2 -2
appview/pages/templates/repo/tree.html
··· 54 54 <div class="flex justify-between items-center"> 55 55 <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 56 56 <div class="flex items-center gap-2"> 57 - {{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }} 57 + {{ i "folder" "size-4 fill-current" }}{{ .Name }} 58 58 </div> 59 59 </a> 60 60 <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> ··· 69 69 <div class="flex justify-between items-center"> 70 70 <a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 71 71 <div class="flex items-center gap-2"> 72 - {{ i "file" "w-3 h-3" }}{{ .Name }} 72 + {{ i "file" "size-4" }}{{ .Name }} 73 73 </div> 74 74 </a> 75 75 <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
+1 -1
appview/pages/templates/timeline.html
··· 23 23 </div> 24 24 <div class="italic text-lg"> 25 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>. 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 27 Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p> 28 28 </div> 29 29 </div>
+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 }}
+97
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 + <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 7 + {{ end }} 8 + </div> 9 + <div class="col-span-2"> 10 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 12 + {{ didOrHandle .UserDid .UserHandle }} 13 + </p> 14 + 15 + <div class="md:hidden"> 16 + {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 17 + </div> 18 + </div> 19 + <div class="col-span-3 md:col-span-full"> 20 + <div id="profile-bio" class="text-sm"> 21 + {{ $profile := .Profile }} 22 + {{ with .Profile }} 23 + 24 + {{ if .Description }} 25 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 26 + {{ end }} 27 + 28 + <div class="hidden md:block"> 29 + {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 30 + </div> 31 + 32 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 33 + {{ if .Location }} 34 + <div class="flex items-center gap-2"> 35 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 36 + <span>{{ .Location }}</span> 37 + </div> 38 + {{ end }} 39 + {{ if .IncludeBluesky }} 40 + <div class="flex items-center gap-2"> 41 + <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 42 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 43 + </div> 44 + {{ end }} 45 + {{ range $link := .Links }} 46 + {{ if $link }} 47 + <div class="flex items-center gap-2"> 48 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 49 + <a href="{{ $link }}">{{ $link }}</a> 50 + </div> 51 + {{ end }} 52 + {{ end }} 53 + {{ if not $profile.IsStatsEmpty }} 54 + <div class="flex items-center justify-evenly gap-2 py-2"> 55 + {{ range $stat := .Stats }} 56 + {{ if $stat.Kind }} 57 + <div class="flex flex-col items-center gap-2"> 58 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 59 + <span>{{ $stat.Kind.String }}</span> 60 + </div> 61 + {{ end }} 62 + {{ end }} 63 + </div> 64 + {{ end }} 65 + </div> 66 + {{ end }} 67 + {{ if ne .FollowStatus.String "IsSelf" }} 68 + {{ template "user/fragments/follow" . }} 69 + {{ else }} 70 + <button id="editBtn" 71 + class="btn mt-2 w-full flex items-center gap-2 group" 72 + hx-target="#profile-bio" 73 + hx-get="/profile/edit-bio" 74 + hx-swap="innerHTML"> 75 + {{ i "pencil" "w-4 h-4" }} 76 + edit 77 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 78 + </button> 79 + {{ end }} 80 + </div> 81 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 82 + </div> 83 + </div> 84 + </div> 85 + {{ end }} 86 + 87 + {{ define "followerFollowing" }} 88 + {{ $followers := index . 0 }} 89 + {{ $following := index . 1 }} 90 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 91 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 92 + <span id="followers">{{ $followers }} followers</span> 93 + <span class="select-none after:content-['ยท']"></span> 94 + <span id="following">{{ $following }} following</span> 95 + </div> 96 + {{ end }} 97 +
+22 -33
appview/pages/templates/user/login.html
··· 8 8 content="width=device-width, initial-scale=1.0" 9 9 /> 10 10 <script src="/static/htmx.min.js"></script> 11 - <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 11 + <link 12 + rel="stylesheet" 13 + href="/static/tw.css?{{ cssContentHash }}" 14 + type="text/css" 15 + /> 12 16 <title>login</title> 13 17 </head> 14 18 <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"> 19 + <main class="max-w-md px-6 -mt-4"> 20 + <h1 21 + class="text-center text-2xl font-semibold italic dark:text-white" 22 + > 17 23 tangled 18 24 </h1> 19 25 <h2 class="text-center text-xl italic dark:text-white"> 20 26 tightly-knit social coding. 21 27 </h2> 22 28 <form 23 - class="w-full mt-4" 29 + class="mt-4 max-w-sm mx-auto" 24 30 hx-post="/login" 25 31 hx-swap="none" 26 - hx-disabled-elt="this" 32 + hx-disabled-elt="#login-button" 27 33 > 28 34 <div class="flex flex-col"> 29 35 <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 36 <input 47 - type="password" 48 - id="app_password" 49 - name="app_password" 50 - tabindex="2" 37 + type="text" 38 + id="handle" 39 + name="handle" 40 + tabindex="1" 51 41 required 52 42 /> 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 - >. 43 + <span class="text-sm text-gray-500 mt-1"> 44 + Use your 45 + <a href="https://bsky.app">Bluesky</a> handle to log 46 + in. You will then be redirected to your PDS to 47 + complete authentication. 60 48 </span> 61 49 </div> 62 50 ··· 70 58 </button> 71 59 </form> 72 60 <p class="text-sm text-gray-500"> 73 - Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel: 61 + Join our <a href="https://chat.tangled.sh">Discord</a> or 62 + IRC channel: 74 63 <a href="https://web.libera.chat/#tangled" 75 64 ><code>#tangled</code> on Libera Chat</a 76 65 >.
+80 -91
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ 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 }} 4 + <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 + <div class="md:col-span-2 order-1 md:order-1"> 6 + {{ template "user/fragments/profileCard" .Card }} 7 7 </div> 8 - <div class="md:col-span-2 order-2 md:order-2"> 8 + <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 9 9 {{ block "ownRepos" . }}{{ end }} 10 10 {{ block "collaboratingRepos" . }}{{ end }} 11 11 </div> 12 - <div class="md:col-span-2 order-3 md:order-3"> 12 + <div class="md:col-span-3 order-3 md:order-3"> 13 13 {{ block "profileTimeline" . }}{{ end }} 14 14 </div> 15 15 </div> 16 16 {{ end }} 17 17 18 18 {{ define "profileTimeline" }} 19 - <p class="text-sm font-bold py-2 dark:text-white px-6">ACTIVITY</p> 19 + <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 20 20 <div class="flex flex-col gap-6 relative"> 21 21 {{ with .ProfileTimeline }} 22 22 {{ range $idx, $byMonth := .ByMonth }} ··· 225 225 {{ end }} 226 226 {{ end }} 227 227 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 }} 228 + {{ define "ownRepos" }} 229 + <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 230 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 231 + class="flex text-black dark:text-white items-center gap-4 no-underline hover:no-underline group"> 232 + <span>PINNED REPOS</span> 233 + <span class="flex md:hidden group-hover:flex gap-2 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 234 + view all {{ i "chevron-right" "w-4 h-4" }} 235 + </span> 236 + </a> 237 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 238 + <button 239 + hx-get="profile/edit-pins" 240 + hx-target="#all-repos" 241 + class="btn font-normal text-sm flex gap-2 items-center group"> 242 + {{ i "pencil" "w-3 h-3" }} 243 + edit 244 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 245 + </button> 246 + {{ end }} 247 + </div> 248 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 249 + {{ range .Repos }} 250 + <div 251 + id="repo-card" 252 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 253 + <div id="repo-card-name" class="font-medium"> 254 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 255 + >{{ .Name }}</a 256 + > 257 + </div> 258 + {{ if .Description }} 259 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 260 + {{ .Description }} 261 + </div> 262 + {{ end }} 263 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 264 + {{ if .RepoStats.StarCount }} 265 + <div class="flex gap-1 items-center text-sm"> 266 + {{ i "star" "w-3 h-3 fill-current" }} 267 + <span>{{ .RepoStats.StarCount }}</span> 268 + </div> 269 + {{ end }} 270 + </div> 235 271 </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> 272 + {{ else }} 273 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 274 + {{ end }} 253 275 </div> 254 276 {{ end }} 255 277 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> 278 + {{ define "collaboratingRepos" }} 279 + {{ if gt (len .CollaboratingRepos) 0 }} 280 + <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 281 + <div id="collaborating" class="grid grid-cols-1 gap-4 mb-6"> 282 + {{ range .CollaboratingRepos }} 283 + <div 284 + id="repo-card" 285 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"> 286 + <div id="repo-card-name" class="font-medium dark:text-white"> 287 + <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 288 + {{ index $.DidHandleMap .Did }}/{{ .Name }} 289 + </a> 285 290 </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> 291 + {{ if .Description }} 292 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 293 + {{ .Description }} 302 294 </div> 303 - {{ if .Description }} 304 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 305 - {{ .Description }} 295 + {{ end }} 296 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 297 + 298 + {{ if .RepoStats.StarCount }} 299 + <div class="flex gap-1 items-center text-sm"> 300 + {{ i "star" "w-3 h-3 fill-current" }} 301 + <span>{{ .RepoStats.StarCount }}</span> 306 302 </div> 307 303 {{ 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 304 </div> 318 - {{ else }} 319 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 320 - {{ end }} 305 + </div> 306 + {{ else }} 307 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 308 + {{ end }} 321 309 </div> 310 + {{ end }} 322 311 {{ end }}
+44
appview/pages/templates/user/repos.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 + <div class="md:col-span-2 order-1 md:order-1"> 6 + {{ template "user/fragments/profileCard" .Card }} 7 + </div> 8 + <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 9 + {{ block "ownRepos" . }}{{ end }} 10 + </div> 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "ownRepos" }} 15 + <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 16 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 17 + {{ range .Repos }} 18 + <div 19 + id="repo-card" 20 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 21 + <div id="repo-card-name" class="font-medium"> 22 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 23 + >{{ .Name }}</a 24 + > 25 + </div> 26 + {{ if .Description }} 27 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 28 + {{ .Description }} 29 + </div> 30 + {{ end }} 31 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 32 + {{ if .RepoStats.StarCount }} 33 + <div class="flex gap-1 items-center text-sm"> 34 + {{ i "star" "w-3 h-3 fill-current" }} 35 + <span>{{ .RepoStats.StarCount }}</span> 36 + </div> 37 + {{ end }} 38 + </div> 39 + </div> 40 + {{ else }} 41 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 42 + {{ end }} 43 + </div> 44 + {{ 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,
+20 -10
appview/state/artifact.go
··· 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 17 "tangled.sh/tangled.sh/core/appview" 18 18 "tangled.sh/tangled.sh/core/appview/db" 19 + "tangled.sh/tangled.sh/core/appview/knotclient" 19 20 "tangled.sh/tangled.sh/core/appview/pages" 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, ··· 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 ··· 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, ··· 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 }
+8 -4
appview/state/follow.go
··· 14 14 ) 15 15 16 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 17 - currentUser := s.auth.GetUser(r) 17 + currentUser := s.oauth.GetUser(r) 18 18 19 19 subject := r.URL.Query().Get("subject") 20 20 if subject == "" { ··· 32 32 return 33 33 } 34 34 35 - client, _ := s.auth.AuthorizedClient(r) 35 + client, err := s.oauth.AuthorizedClient(r) 36 + if err != nil { 37 + log.Println("failed to authorize client") 38 + return 39 + } 36 40 37 41 switch r.Method { 38 42 case http.MethodPost: 39 43 createdAt := time.Now().Format(time.RFC3339) 40 44 rkey := appview.TID() 41 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 45 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 42 46 Collection: tangled.GraphFollowNSID, 43 47 Repo: currentUser.Did, 44 48 Rkey: rkey, ··· 75 79 return 76 80 } 77 81 78 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 82 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 79 83 Collection: tangled.GraphFollowNSID, 80 84 Repo: currentUser.Did, 81 85 Rkey: follow.Rkey,
+2 -2
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 21 targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) ··· 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 58 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
+36 -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 ··· 175 176 }) 176 177 } 177 178 } 179 + 180 + // this should serve the go-import meta tag even if the path is technically 181 + // a 404 like tangled.sh/oppi.li/go-git/v5 182 + func GoImport(s *State) middleware.Middleware { 183 + return func(next http.Handler) http.Handler { 184 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 185 + f, err := s.fullyResolvedRepo(r) 186 + if err != nil { 187 + log.Println("failed to fully resolve repo", err) 188 + http.Error(w, "invalid repo url", http.StatusNotFound) 189 + return 190 + } 191 + 192 + fullName := f.OwnerHandle() + "/" + f.RepoName 193 + 194 + if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 195 + if r.URL.Query().Get("go-get") == "1" { 196 + html := fmt.Sprintf( 197 + `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 198 + fullName, 199 + fullName, 200 + ) 201 + w.Header().Set("Content-Type", "text/html") 202 + w.Write([]byte(html)) 203 + return 204 + } 205 + } 206 + 207 + next.ServeHTTP(w, r) 208 + }) 209 + } 210 + }
+328 -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 + "tangled.sh/tangled.sh/core/api/tangled" 13 19 "tangled.sh/tangled.sh/core/appview/db" 14 20 "tangled.sh/tangled.sh/core/appview/pages" 15 21 ) 16 22 17 - func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 23 + func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 + tabVal := r.URL.Query().Get("tab") 25 + switch tabVal { 26 + case "": 27 + s.profilePage(w, r) 28 + case "repos": 29 + s.reposPage(w, r) 30 + } 31 + } 32 + 33 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 18 34 didOrHandle := chi.URLParam(r, "user") 19 35 if didOrHandle == "" { 20 36 http.Error(w, "Bad request", http.StatusBadRequest) ··· 27 43 return 28 44 } 29 45 46 + profile, err := db.GetProfile(s.db, ident.DID.String()) 47 + if err != nil { 48 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 49 + } 50 + 30 51 repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 31 52 if err != nil { 32 53 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 33 54 } 34 55 56 + // filter out ones that are pinned 57 + pinnedRepos := []db.Repo{} 58 + for i, r := range repos { 59 + // if this is a pinned repo, add it 60 + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 61 + pinnedRepos = append(pinnedRepos, r) 62 + } 63 + 64 + // if there are no saved pins, add the first 4 repos 65 + if profile.IsPinnedReposEmpty() && i < 4 { 66 + pinnedRepos = append(pinnedRepos, r) 67 + } 68 + } 69 + 35 70 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 36 71 if err != nil { 37 72 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 38 73 } 39 74 75 + pinnedCollaboratingRepos := []db.Repo{} 76 + for _, r := range collaboratingRepos { 77 + // if this is a pinned repo, add it 78 + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 79 + pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 80 + } 81 + } 82 + 40 83 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 41 84 if err != nil { 42 85 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) ··· 76 119 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 77 120 } 78 121 79 - loggedInUser := s.auth.GetUser(r) 122 + loggedInUser := s.oauth.GetUser(r) 80 123 followStatus := db.IsNotFollowing 81 124 if loggedInUser != nil { 82 125 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) ··· 85 128 profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 86 129 s.pages.ProfilePage(w, pages.ProfilePageParams{ 87 130 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, 131 + Repos: pinnedRepos, 132 + CollaboratingRepos: pinnedCollaboratingRepos, 133 + DidHandleMap: didHandleMap, 134 + Card: pages.ProfileCard{ 135 + UserDid: ident.DID.String(), 136 + UserHandle: ident.Handle.String(), 137 + AvatarUri: profileAvatarUri, 138 + Profile: profile, 139 + FollowStatus: followStatus, 140 + Followers: followers, 141 + Following: following, 95 142 }, 96 - FollowStatus: db.FollowStatus(followStatus), 97 - DidHandleMap: didHandleMap, 98 - AvatarUri: profileAvatarUri, 99 143 ProfileTimeline: timeline, 100 144 }) 101 145 } 102 146 147 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 148 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 149 + if !ok { 150 + s.pages.Error404(w) 151 + return 152 + } 153 + 154 + profile, err := db.GetProfile(s.db, ident.DID.String()) 155 + if err != nil { 156 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 157 + } 158 + 159 + repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 160 + if err != nil { 161 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 162 + } 163 + 164 + loggedInUser := s.oauth.GetUser(r) 165 + followStatus := db.IsNotFollowing 166 + if loggedInUser != nil { 167 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 168 + } 169 + 170 + followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 171 + if err != nil { 172 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 173 + } 174 + 175 + profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 176 + 177 + s.pages.ReposPage(w, pages.ReposPageParams{ 178 + LoggedInUser: loggedInUser, 179 + Repos: repos, 180 + Card: pages.ProfileCard{ 181 + UserDid: ident.DID.String(), 182 + UserHandle: ident.Handle.String(), 183 + AvatarUri: profileAvatarUri, 184 + Profile: profile, 185 + FollowStatus: followStatus, 186 + Followers: followers, 187 + Following: following, 188 + }, 189 + }) 190 + } 191 + 103 192 func (s *State) GetAvatarUri(handle string) string { 104 - secret := s.config.AvatarSharedSecret 193 + secret := s.config.Avatar.SharedSecret 105 194 h := hmac.New(sha256.New, []byte(secret)) 106 195 h.Write([]byte(handle)) 107 196 signature := hex.EncodeToString(h.Sum(nil)) 108 - return fmt.Sprintf("%s/%s/%s", s.config.AvatarHost, signature, handle) 197 + return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 198 + } 199 + 200 + func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 201 + user := s.oauth.GetUser(r) 202 + 203 + err := r.ParseForm() 204 + if err != nil { 205 + log.Println("invalid profile update form", err) 206 + s.pages.Notice(w, "update-profile", "Invalid form.") 207 + return 208 + } 209 + 210 + profile, err := db.GetProfile(s.db, user.Did) 211 + if err != nil { 212 + log.Printf("getting profile data for %s: %s", user.Did, err) 213 + } 214 + 215 + profile.Description = r.FormValue("description") 216 + profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 217 + profile.Location = r.FormValue("location") 218 + 219 + var links [5]string 220 + for i := range 5 { 221 + iLink := r.FormValue(fmt.Sprintf("link%d", i)) 222 + links[i] = iLink 223 + } 224 + profile.Links = links 225 + 226 + // Parse stats (exactly 2) 227 + stat0 := r.FormValue("stat0") 228 + stat1 := r.FormValue("stat1") 229 + 230 + if stat0 != "" { 231 + profile.Stats[0].Kind = db.VanityStatKind(stat0) 232 + } 233 + 234 + if stat1 != "" { 235 + profile.Stats[1].Kind = db.VanityStatKind(stat1) 236 + } 237 + 238 + if err := db.ValidateProfile(s.db, profile); err != nil { 239 + log.Println("invalid profile", err) 240 + s.pages.Notice(w, "update-profile", err.Error()) 241 + return 242 + } 243 + 244 + s.updateProfile(profile, w, r) 245 + return 246 + } 247 + 248 + func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 249 + user := s.oauth.GetUser(r) 250 + 251 + err := r.ParseForm() 252 + if err != nil { 253 + log.Println("invalid profile update form", err) 254 + s.pages.Notice(w, "update-profile", "Invalid form.") 255 + return 256 + } 257 + 258 + profile, err := db.GetProfile(s.db, user.Did) 259 + if err != nil { 260 + log.Printf("getting profile data for %s: %s", user.Did, err) 261 + } 262 + 263 + i := 0 264 + var pinnedRepos [6]syntax.ATURI 265 + for key, values := range r.Form { 266 + if i >= 6 { 267 + log.Println("invalid pin update form", err) 268 + s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 269 + return 270 + } 271 + if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 272 + aturi, err := syntax.ParseATURI(values[0]) 273 + if err != nil { 274 + log.Println("invalid profile update form", err) 275 + s.pages.Notice(w, "update-profile", "Invalid form.") 276 + return 277 + } 278 + pinnedRepos[i] = aturi 279 + i++ 280 + } 281 + } 282 + profile.PinnedRepos = pinnedRepos 283 + 284 + s.updateProfile(profile, w, r) 285 + return 286 + } 287 + 288 + func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 289 + user := s.oauth.GetUser(r) 290 + tx, err := s.db.BeginTx(r.Context(), nil) 291 + if err != nil { 292 + log.Println("failed to start transaction", err) 293 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 294 + return 295 + } 296 + 297 + client, err := s.oauth.AuthorizedClient(r) 298 + if err != nil { 299 + log.Println("failed to get authorized client", err) 300 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 301 + return 302 + } 303 + 304 + // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 305 + // nor does it support exact size arrays 306 + var pinnedRepoStrings []string 307 + for _, r := range profile.PinnedRepos { 308 + pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 309 + } 310 + 311 + var vanityStats []string 312 + for _, v := range profile.Stats { 313 + vanityStats = append(vanityStats, string(v.Kind)) 314 + } 315 + 316 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 317 + var cid *string 318 + if ex != nil { 319 + cid = ex.Cid 320 + } 321 + 322 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 323 + Collection: tangled.ActorProfileNSID, 324 + Repo: user.Did, 325 + Rkey: "self", 326 + Record: &lexutil.LexiconTypeDecoder{ 327 + Val: &tangled.ActorProfile{ 328 + Bluesky: profile.IncludeBluesky, 329 + Description: &profile.Description, 330 + Links: profile.Links[:], 331 + Location: &profile.Location, 332 + PinnedRepositories: pinnedRepoStrings, 333 + Stats: vanityStats[:], 334 + }}, 335 + SwapRecord: cid, 336 + }) 337 + if err != nil { 338 + log.Println("failed to update profile", err) 339 + s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 340 + return 341 + } 342 + 343 + err = db.UpsertProfile(tx, profile) 344 + if err != nil { 345 + log.Println("failed to update profile", err) 346 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 347 + return 348 + } 349 + 350 + s.pages.HxRedirect(w, "/"+user.Did) 351 + return 352 + } 353 + 354 + func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 355 + user := s.oauth.GetUser(r) 356 + 357 + profile, err := db.GetProfile(s.db, user.Did) 358 + if err != nil { 359 + log.Printf("getting profile data for %s: %s", user.Did, err) 360 + } 361 + 362 + s.pages.EditBioFragment(w, pages.EditBioParams{ 363 + LoggedInUser: user, 364 + Profile: profile, 365 + }) 366 + } 367 + 368 + func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 369 + user := s.oauth.GetUser(r) 370 + 371 + profile, err := db.GetProfile(s.db, user.Did) 372 + if err != nil { 373 + log.Printf("getting profile data for %s: %s", user.Did, err) 374 + } 375 + 376 + repos, err := db.GetAllReposByDid(s.db, user.Did) 377 + if err != nil { 378 + log.Printf("getting repos for %s: %s", user.Did, err) 379 + } 380 + 381 + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 382 + if err != nil { 383 + log.Printf("getting collaborating repos for %s: %s", user.Did, err) 384 + } 385 + 386 + allRepos := []pages.PinnedRepo{} 387 + 388 + for _, r := range repos { 389 + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 390 + allRepos = append(allRepos, pages.PinnedRepo{ 391 + IsPinned: isPinned, 392 + Repo: r, 393 + }) 394 + } 395 + for _, r := range collaboratingRepos { 396 + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 397 + allRepos = append(allRepos, pages.PinnedRepo{ 398 + IsPinned: isPinned, 399 + Repo: r, 400 + }) 401 + } 402 + 403 + var didsToResolve []string 404 + for _, r := range allRepos { 405 + didsToResolve = append(didsToResolve, r.Did) 406 + } 407 + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 408 + didHandleMap := make(map[string]string) 409 + for _, identity := range resolvedIds { 410 + if !identity.Handle.IsInvalidHandle() { 411 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 412 + } else { 413 + didHandleMap[identity.DID.String()] = identity.DID.String() 414 + } 415 + } 416 + 417 + s.pages.EditPinsFragment(w, pages.EditPinsParams{ 418 + LoggedInUser: user, 419 + Profile: profile, 420 + AllRepos: allRepos, 421 + DidHandleMap: didHandleMap, 422 + }) 109 423 }
+84 -53
appview/state/pull.go
··· 13 13 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" 17 + "tangled.sh/tangled.sh/core/appview/knotclient" 18 + "tangled.sh/tangled.sh/core/appview/oauth" 18 19 "tangled.sh/tangled.sh/core/appview/pages" 19 20 "tangled.sh/tangled.sh/core/patchutil" 20 21 "tangled.sh/tangled.sh/core/types" ··· 29 30 func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 30 31 switch r.Method { 31 32 case http.MethodGet: 32 - user := s.auth.GetUser(r) 33 + user := s.oauth.GetUser(r) 33 34 f, err := s.fullyResolvedRepo(r) 34 35 if err != nil { 35 36 log.Println("failed to get repo and knot", err) ··· 73 74 } 74 75 75 76 func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 76 - user := s.auth.GetUser(r) 77 + user := s.oauth.GetUser(r) 77 78 f, err := s.fullyResolvedRepo(r) 78 79 if err != nil { 79 80 log.Println("failed to get repo and knot", err) ··· 143 144 } 144 145 } 145 146 146 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 147 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 147 148 if err != nil { 148 149 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 149 150 return types.MergeCheckResponse{ ··· 215 216 repoName = f.RepoName 216 217 } 217 218 218 - us, err := NewUnsignedClient(knot, s.config.Dev) 219 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 219 220 if err != nil { 220 221 log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 221 222 return pages.Unknown ··· 250 251 } 251 252 252 253 func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 253 - user := s.auth.GetUser(r) 254 + user := s.oauth.GetUser(r) 254 255 f, err := s.fullyResolvedRepo(r) 255 256 if err != nil { 256 257 log.Println("failed to get repo and knot", err) ··· 298 299 } 299 300 300 301 func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 301 - user := s.auth.GetUser(r) 302 + user := s.oauth.GetUser(r) 302 303 303 304 f, err := s.fullyResolvedRepo(r) 304 305 if err != nil { ··· 355 356 interdiff := patchutil.Interdiff(previousPatch, currentPatch) 356 357 357 358 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 358 - LoggedInUser: s.auth.GetUser(r), 359 + LoggedInUser: s.oauth.GetUser(r), 359 360 RepoInfo: f.RepoInfo(s, user), 360 361 Pull: pull, 361 362 Round: roundIdInt, ··· 397 398 } 398 399 399 400 func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 400 - user := s.auth.GetUser(r) 401 + user := s.oauth.GetUser(r) 401 402 params := r.URL.Query() 402 403 403 404 state := db.PullOpen ··· 451 452 } 452 453 453 454 s.pages.RepoPulls(w, pages.RepoPullsParams{ 454 - LoggedInUser: s.auth.GetUser(r), 455 + LoggedInUser: s.oauth.GetUser(r), 455 456 RepoInfo: f.RepoInfo(s, user), 456 457 Pulls: pulls, 457 458 DidHandleMap: didHandleMap, ··· 461 462 } 462 463 463 464 func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 464 - user := s.auth.GetUser(r) 465 + user := s.oauth.GetUser(r) 465 466 f, err := s.fullyResolvedRepo(r) 466 467 if err != nil { 467 468 log.Println("failed to get repo and knot", err) ··· 519 520 } 520 521 521 522 atUri := f.RepoAt.String() 522 - client, _ := s.auth.AuthorizedClient(r) 523 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 523 + client, err := s.oauth.AuthorizedClient(r) 524 + if err != nil { 525 + log.Println("failed to get authorized client", err) 526 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 527 + return 528 + } 529 + atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 524 530 Collection: tangled.RepoPullCommentNSID, 525 531 Repo: user.Did, 526 532 Rkey: appview.TID(), ··· 568 574 } 569 575 570 576 func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 571 - user := s.auth.GetUser(r) 577 + user := s.oauth.GetUser(r) 572 578 f, err := s.fullyResolvedRepo(r) 573 579 if err != nil { 574 580 log.Println("failed to get repo and knot", err) ··· 577 583 578 584 switch r.Method { 579 585 case http.MethodGet: 580 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 586 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 581 587 if err != nil { 582 588 log.Printf("failed to create unsigned client for %s", f.Knot) 583 589 s.pages.Error503(w) ··· 646 652 return 647 653 } 648 654 649 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 655 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 650 656 if err != nil { 651 657 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 652 658 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") ··· 689 695 } 690 696 } 691 697 692 - func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { 698 + func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, sourceBranch string) { 693 699 pullSource := &db.PullSource{ 694 700 Branch: sourceBranch, 695 701 } ··· 698 704 } 699 705 700 706 // Generate a patch using /compare 701 - ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 707 + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 702 708 if err != nil { 703 709 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 704 710 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 723 729 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 724 730 } 725 731 726 - func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 732 + func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string) { 727 733 if !patchutil.IsPatchValid(patch) { 728 734 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 729 735 return ··· 732 738 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 733 739 } 734 740 735 - func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 741 + func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 736 742 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 737 743 if errors.Is(err, sql.ErrNoRows) { 738 744 s.pages.Notice(w, "pull", "No such fork.") ··· 750 756 return 751 757 } 752 758 753 - sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) 759 + sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 754 760 if err != nil { 755 761 log.Println("failed to create signed client:", err) 756 762 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 757 763 return 758 764 } 759 765 760 - us, err := NewUnsignedClient(fork.Knot, s.config.Dev) 766 + us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 761 767 if err != nil { 762 768 log.Println("failed to create unsigned client:", err) 763 769 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 816 822 w http.ResponseWriter, 817 823 r *http.Request, 818 824 f *FullyResolvedRepo, 819 - user *auth.User, 825 + user *oauth.User, 820 826 title, body, targetBranch string, 821 827 patch string, 822 828 sourceRev string, ··· 870 876 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 871 877 return 872 878 } 873 - client, _ := s.auth.AuthorizedClient(r) 874 - pullId, err := db.NextPullId(s.db, f.RepoAt) 879 + client, err := s.oauth.AuthorizedClient(r) 880 + if err != nil { 881 + log.Println("failed to get authorized client", err) 882 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 883 + return 884 + } 885 + pullId, err := db.NextPullId(tx, f.RepoAt) 875 886 if err != nil { 876 887 log.Println("failed to get pull id", err) 877 888 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 878 889 return 879 890 } 880 891 881 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 892 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 882 893 Collection: tangled.RepoPullNSID, 883 894 Repo: user.Did, 884 895 Rkey: rkey, ··· 893 904 }, 894 905 }, 895 906 }) 907 + if err != nil { 908 + log.Println("failed to create pull request", err) 909 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 910 + return 911 + } 896 912 897 - if err != nil { 913 + if err = tx.Commit(); err != nil { 898 914 log.Println("failed to create pull request", err) 899 915 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 900 916 return ··· 929 945 } 930 946 931 947 func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 932 - user := s.auth.GetUser(r) 948 + user := s.oauth.GetUser(r) 933 949 f, err := s.fullyResolvedRepo(r) 934 950 if err != nil { 935 951 log.Println("failed to get repo and knot", err) ··· 942 958 } 943 959 944 960 func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 945 - user := s.auth.GetUser(r) 961 + user := s.oauth.GetUser(r) 946 962 f, err := s.fullyResolvedRepo(r) 947 963 if err != nil { 948 964 log.Println("failed to get repo and knot", err) 949 965 return 950 966 } 951 967 952 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 968 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 953 969 if err != nil { 954 970 log.Printf("failed to create unsigned client for %s", f.Knot) 955 971 s.pages.Error503(w) ··· 982 998 } 983 999 984 1000 func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 985 - user := s.auth.GetUser(r) 1001 + user := s.oauth.GetUser(r) 986 1002 f, err := s.fullyResolvedRepo(r) 987 1003 if err != nil { 988 1004 log.Println("failed to get repo and knot", err) ··· 1002 1018 } 1003 1019 1004 1020 func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1005 - user := s.auth.GetUser(r) 1021 + user := s.oauth.GetUser(r) 1006 1022 1007 1023 f, err := s.fullyResolvedRepo(r) 1008 1024 if err != nil { ··· 1019 1035 return 1020 1036 } 1021 1037 1022 - sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) 1038 + sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1023 1039 if err != nil { 1024 1040 log.Printf("failed to create unsigned client for %s", repo.Knot) 1025 1041 s.pages.Error503(w) ··· 1046 1062 return 1047 1063 } 1048 1064 1049 - targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 1065 + targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1050 1066 if err != nil { 1051 1067 log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1052 1068 s.pages.Error503(w) ··· 1081 1097 } 1082 1098 1083 1099 func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1084 - user := s.auth.GetUser(r) 1100 + user := s.oauth.GetUser(r) 1085 1101 f, err := s.fullyResolvedRepo(r) 1086 1102 if err != nil { 1087 1103 log.Println("failed to get repo and knot", err) ··· 1117 1133 } 1118 1134 1119 1135 func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1120 - user := s.auth.GetUser(r) 1136 + user := s.oauth.GetUser(r) 1121 1137 1122 1138 pull, ok := r.Context().Value("pull").(*db.Pull) 1123 1139 if !ok { ··· 1159 1175 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.") 1160 1176 return 1161 1177 } 1162 - client, _ := s.auth.AuthorizedClient(r) 1178 + client, err := s.oauth.AuthorizedClient(r) 1179 + if err != nil { 1180 + log.Println("failed to get authorized client", err) 1181 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1182 + return 1183 + } 1163 1184 1164 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1185 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1165 1186 if err != nil { 1166 1187 // failed to get record 1167 1188 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1168 1189 return 1169 1190 } 1170 1191 1171 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1192 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1172 1193 Collection: tangled.RepoPullNSID, 1173 1194 Repo: user.Did, 1174 1195 Rkey: pull.Rkey, ··· 1200 1221 } 1201 1222 1202 1223 func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1203 - user := s.auth.GetUser(r) 1224 + user := s.oauth.GetUser(r) 1204 1225 1205 1226 pull, ok := r.Context().Value("pull").(*db.Pull) 1206 1227 if !ok { ··· 1227 1248 return 1228 1249 } 1229 1250 1230 - ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 1251 + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1231 1252 if err != nil { 1232 1253 log.Printf("failed to create client for %s: %s", f.Knot, err) 1233 1254 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1268 1289 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1269 1290 return 1270 1291 } 1271 - client, _ := s.auth.AuthorizedClient(r) 1292 + client, err := s.oauth.AuthorizedClient(r) 1293 + if err != nil { 1294 + log.Println("failed to authorize client") 1295 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1296 + return 1297 + } 1272 1298 1273 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1299 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1274 1300 if err != nil { 1275 1301 // failed to get record 1276 1302 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1280 1306 recordPullSource := &tangled.RepoPull_Source{ 1281 1307 Branch: pull.PullSource.Branch, 1282 1308 } 1283 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1309 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1284 1310 Collection: tangled.RepoPullNSID, 1285 1311 Repo: user.Did, 1286 1312 Rkey: pull.Rkey, ··· 1313 1339 } 1314 1340 1315 1341 func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { 1316 - user := s.auth.GetUser(r) 1342 + user := s.oauth.GetUser(r) 1317 1343 1318 1344 pull, ok := r.Context().Value("pull").(*db.Pull) 1319 1345 if !ok { ··· 1342 1368 } 1343 1369 1344 1370 // extract patch by performing compare 1345 - ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev) 1371 + ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1346 1372 if err != nil { 1347 1373 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1348 1374 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1357 1383 } 1358 1384 1359 1385 // update the hidden tracking branch to latest 1360 - signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev) 1386 + signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1361 1387 if err != nil { 1362 1388 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1363 1389 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1406 1432 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1407 1433 return 1408 1434 } 1409 - client, _ := s.auth.AuthorizedClient(r) 1435 + client, err := s.oauth.AuthorizedClient(r) 1436 + if err != nil { 1437 + log.Println("failed to get client") 1438 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1439 + return 1440 + } 1410 1441 1411 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1442 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1412 1443 if err != nil { 1413 1444 // failed to get record 1414 1445 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1420 1451 Branch: pull.PullSource.Branch, 1421 1452 Repo: &repoAt, 1422 1453 } 1423 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1454 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1424 1455 Collection: tangled.RepoPullNSID, 1425 1456 Repo: user.Did, 1426 1457 Rkey: pull.Rkey, ··· 1503 1534 log.Printf("failed to get primary email: %s", err) 1504 1535 } 1505 1536 1506 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1537 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1507 1538 if err != nil { 1508 1539 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1509 1540 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1533 1564 } 1534 1565 1535 1566 func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1536 - user := s.auth.GetUser(r) 1567 + user := s.oauth.GetUser(r) 1537 1568 1538 1569 f, err := s.fullyResolvedRepo(r) 1539 1570 if err != nil { ··· 1587 1618 } 1588 1619 1589 1620 func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1590 - user := s.auth.GetUser(r) 1621 + user := s.oauth.GetUser(r) 1591 1622 1592 1623 f, err := s.fullyResolvedRepo(r) 1593 1624 if err != nil {
+99 -60
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/knotclient" 23 + "tangled.sh/tangled.sh/core/appview/oauth" 23 24 "tangled.sh/tangled.sh/core/appview/pages" 24 25 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" ··· 45 46 return 46 47 } 47 48 48 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 49 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 49 50 if err != nil { 50 51 log.Printf("failed to create unsigned client for %s", f.Knot) 51 52 s.pages.Error503(w) ··· 119 120 120 121 emails := uniqueEmails(commitsTrunc) 121 122 122 - user := s.auth.GetUser(r) 123 + user := s.oauth.GetUser(r) 123 124 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 124 125 LoggedInUser: user, 125 126 RepoInfo: f.RepoInfo(s, user), ··· 150 151 151 152 ref := chi.URLParam(r, "ref") 152 153 153 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 154 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 154 155 if err != nil { 155 156 log.Println("failed to create unsigned client", err) 156 157 return ··· 190 191 tagMap[hash] = append(tagMap[hash], tag.Name) 191 192 } 192 193 193 - user := s.auth.GetUser(r) 194 + user := s.oauth.GetUser(r) 194 195 s.pages.RepoLog(w, pages.RepoLogParams{ 195 196 LoggedInUser: user, 196 197 TagMap: tagMap, ··· 209 210 return 210 211 } 211 212 212 - user := s.auth.GetUser(r) 213 + user := s.oauth.GetUser(r) 213 214 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 214 215 RepoInfo: f.RepoInfo(s, user), 215 216 }) ··· 232 233 return 233 234 } 234 235 235 - user := s.auth.GetUser(r) 236 + user := s.oauth.GetUser(r) 236 237 237 238 switch r.Method { 238 239 case http.MethodGet: ··· 241 242 }) 242 243 return 243 244 case http.MethodPut: 244 - user := s.auth.GetUser(r) 245 + user := s.oauth.GetUser(r) 245 246 newDescription := r.FormValue("description") 246 - client, _ := s.auth.AuthorizedClient(r) 247 + client, err := s.oauth.AuthorizedClient(r) 248 + if err != nil { 249 + log.Println("failed to get client") 250 + s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 251 + return 252 + } 247 253 248 254 // optimistic update 249 255 err = db.UpdateDescription(s.db, string(repoAt), newDescription) ··· 256 262 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 257 263 // 258 264 // 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) 265 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 260 266 if err != nil { 261 267 // failed to get record 262 268 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 263 269 return 264 270 } 265 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 271 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 266 272 Collection: tangled.RepoNSID, 267 273 Repo: user.Did, 268 274 Rkey: rkey, ··· 303 309 } 304 310 ref := chi.URLParam(r, "ref") 305 311 protocol := "http" 306 - if !s.config.Dev { 312 + if !s.config.Core.Dev { 307 313 protocol = "https" 308 314 } 309 315 ··· 331 337 return 332 338 } 333 339 334 - user := s.auth.GetUser(r) 340 + user := s.oauth.GetUser(r) 335 341 s.pages.RepoCommit(w, pages.RepoCommitParams{ 336 342 LoggedInUser: user, 337 343 RepoInfo: f.RepoInfo(s, user), ··· 351 357 ref := chi.URLParam(r, "ref") 352 358 treePath := chi.URLParam(r, "*") 353 359 protocol := "http" 354 - if !s.config.Dev { 360 + if !s.config.Core.Dev { 355 361 protocol = "https" 356 362 } 357 363 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) ··· 380 386 return 381 387 } 382 388 383 - user := s.auth.GetUser(r) 389 + user := s.oauth.GetUser(r) 384 390 385 391 var breadcrumbs [][]string 386 392 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) ··· 411 417 return 412 418 } 413 419 414 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 420 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 415 421 if err != nil { 416 422 log.Println("failed to create unsigned client", err) 417 423 return ··· 451 457 } 452 458 } 453 459 454 - user := s.auth.GetUser(r) 460 + user := s.oauth.GetUser(r) 455 461 s.pages.RepoTags(w, pages.RepoTagsParams{ 456 462 LoggedInUser: user, 457 463 RepoInfo: f.RepoInfo(s, user), ··· 469 475 return 470 476 } 471 477 472 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 478 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 473 479 if err != nil { 474 480 log.Println("failed to create unsigned client", err) 475 481 return ··· 511 517 return strings.Compare(a.Name, b.Name) * -1 512 518 }) 513 519 514 - user := s.auth.GetUser(r) 520 + user := s.oauth.GetUser(r) 515 521 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 516 522 LoggedInUser: user, 517 523 RepoInfo: f.RepoInfo(s, user), ··· 530 536 ref := chi.URLParam(r, "ref") 531 537 filePath := chi.URLParam(r, "*") 532 538 protocol := "http" 533 - if !s.config.Dev { 539 + if !s.config.Core.Dev { 534 540 protocol = "https" 535 541 } 536 542 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) ··· 568 574 showRendered = r.URL.Query().Get("code") != "true" 569 575 } 570 576 571 - user := s.auth.GetUser(r) 577 + user := s.oauth.GetUser(r) 572 578 s.pages.RepoBlob(w, pages.RepoBlobParams{ 573 579 LoggedInUser: user, 574 580 RepoInfo: f.RepoInfo(s, user), ··· 591 597 filePath := chi.URLParam(r, "*") 592 598 593 599 protocol := "http" 594 - if !s.config.Dev { 600 + if !s.config.Core.Dev { 595 601 protocol = "https" 596 602 } 597 603 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) ··· 652 658 return 653 659 } 654 660 655 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 661 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 656 662 if err != nil { 657 663 log.Println("failed to create client to ", f.Knot) 658 664 return ··· 714 720 } 715 721 716 722 func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 717 - user := s.auth.GetUser(r) 723 + user := s.oauth.GetUser(r) 718 724 719 725 f, err := s.fullyResolvedRepo(r) 720 726 if err != nil { ··· 723 729 } 724 730 725 731 // remove record from pds 726 - xrpcClient, _ := s.auth.AuthorizedClient(r) 732 + xrpcClient, err := s.oauth.AuthorizedClient(r) 733 + if err != nil { 734 + log.Println("failed to get authorized client", err) 735 + return 736 + } 727 737 repoRkey := f.RepoAt.RecordKey().String() 728 - _, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{ 738 + _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 729 739 Collection: tangled.RepoNSID, 730 740 Repo: user.Did, 731 741 Rkey: repoRkey, ··· 743 753 return 744 754 } 745 755 746 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 756 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 747 757 if err != nil { 748 758 log.Println("failed to create client to ", f.Knot) 749 759 return ··· 838 848 return 839 849 } 840 850 841 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 851 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 842 852 if err != nil { 843 853 log.Println("failed to create client to ", f.Knot) 844 854 return ··· 868 878 switch r.Method { 869 879 case http.MethodGet: 870 880 // for now, this is just pubkeys 871 - user := s.auth.GetUser(r) 881 + user := s.oauth.GetUser(r) 872 882 repoCollaborators, err := f.Collaborators(r.Context(), s) 873 883 if err != nil { 874 884 log.Println("failed to get collaborators", err) ··· 884 894 885 895 var branchNames []string 886 896 var defaultBranch string 887 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 897 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 888 898 if err != nil { 889 899 log.Println("failed to create unsigned client", err) 890 900 } else { ··· 1008 1018 return collaborators, nil 1009 1019 } 1010 1020 1011 - func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) repoinfo.RepoInfo { 1021 + func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1012 1022 isStarred := false 1013 1023 if u != nil { 1014 1024 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) ··· 1051 1061 1052 1062 knot := f.Knot 1053 1063 var disableFork bool 1054 - us, err := NewUnsignedClient(knot, s.config.Dev) 1064 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1055 1065 if err != nil { 1056 1066 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1057 1067 } else { ··· 1105 1115 } 1106 1116 1107 1117 func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1108 - user := s.auth.GetUser(r) 1118 + user := s.oauth.GetUser(r) 1109 1119 f, err := s.fullyResolvedRepo(r) 1110 1120 if err != nil { 1111 1121 log.Println("failed to get repo and knot", err) ··· 1159 1169 } 1160 1170 1161 1171 func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1162 - user := s.auth.GetUser(r) 1172 + user := s.oauth.GetUser(r) 1163 1173 f, err := s.fullyResolvedRepo(r) 1164 1174 if err != nil { 1165 1175 log.Println("failed to get repo and knot", err) ··· 1195 1205 1196 1206 closed := tangled.RepoIssueStateClosed 1197 1207 1198 - client, _ := s.auth.AuthorizedClient(r) 1199 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1208 + client, err := s.oauth.AuthorizedClient(r) 1209 + if err != nil { 1210 + log.Println("failed to get authorized client", err) 1211 + return 1212 + } 1213 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1200 1214 Collection: tangled.RepoIssueStateNSID, 1201 1215 Repo: user.Did, 1202 1216 Rkey: appview.TID(), ··· 1214 1228 return 1215 1229 } 1216 1230 1217 - err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1231 + err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1218 1232 if err != nil { 1219 1233 log.Println("failed to close issue", err) 1220 1234 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 1231 1245 } 1232 1246 1233 1247 func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1234 - user := s.auth.GetUser(r) 1248 + user := s.oauth.GetUser(r) 1235 1249 f, err := s.fullyResolvedRepo(r) 1236 1250 if err != nil { 1237 1251 log.Println("failed to get repo and knot", err) ··· 1279 1293 } 1280 1294 1281 1295 func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1282 - user := s.auth.GetUser(r) 1296 + user := s.oauth.GetUser(r) 1283 1297 f, err := s.fullyResolvedRepo(r) 1284 1298 if err != nil { 1285 1299 log.Println("failed to get repo and knot", err) ··· 1330 1344 } 1331 1345 1332 1346 atUri := f.RepoAt.String() 1333 - client, _ := s.auth.AuthorizedClient(r) 1334 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1347 + client, err := s.oauth.AuthorizedClient(r) 1348 + if err != nil { 1349 + log.Println("failed to get authorized client", err) 1350 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1351 + return 1352 + } 1353 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1335 1354 Collection: tangled.RepoIssueCommentNSID, 1336 1355 Repo: user.Did, 1337 1356 Rkey: rkey, ··· 1358 1377 } 1359 1378 1360 1379 func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1361 - user := s.auth.GetUser(r) 1380 + user := s.oauth.GetUser(r) 1362 1381 f, err := s.fullyResolvedRepo(r) 1363 1382 if err != nil { 1364 1383 log.Println("failed to get repo and knot", err) ··· 1417 1436 } 1418 1437 1419 1438 func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1420 - user := s.auth.GetUser(r) 1439 + user := s.oauth.GetUser(r) 1421 1440 f, err := s.fullyResolvedRepo(r) 1422 1441 if err != nil { 1423 1442 log.Println("failed to get repo and knot", err) ··· 1469 1488 case http.MethodPost: 1470 1489 // extract form value 1471 1490 newBody := r.FormValue("body") 1472 - client, _ := s.auth.AuthorizedClient(r) 1491 + client, err := s.oauth.AuthorizedClient(r) 1492 + if err != nil { 1493 + log.Println("failed to get authorized client", err) 1494 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1495 + return 1496 + } 1473 1497 rkey := comment.Rkey 1474 1498 1475 1499 // optimistic update ··· 1484 1508 // rkey is optional, it was introduced later 1485 1509 if comment.Rkey != "" { 1486 1510 // update the record on pds 1487 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1511 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1488 1512 if err != nil { 1489 1513 // failed to get record 1490 1514 log.Println(err, rkey) ··· 1499 1523 createdAt := record["createdAt"].(string) 1500 1524 commentIdInt64 := int64(commentIdInt) 1501 1525 1502 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1526 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1503 1527 Collection: tangled.RepoIssueCommentNSID, 1504 1528 Repo: user.Did, 1505 1529 Rkey: rkey, ··· 1542 1566 } 1543 1567 1544 1568 func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1545 - user := s.auth.GetUser(r) 1569 + user := s.oauth.GetUser(r) 1546 1570 f, err := s.fullyResolvedRepo(r) 1547 1571 if err != nil { 1548 1572 log.Println("failed to get repo and knot", err) ··· 1599 1623 1600 1624 // delete from pds 1601 1625 if comment.Rkey != "" { 1602 - client, _ := s.auth.AuthorizedClient(r) 1603 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1626 + client, err := s.oauth.AuthorizedClient(r) 1627 + if err != nil { 1628 + log.Println("failed to get authorized client", err) 1629 + s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1630 + return 1631 + } 1632 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1604 1633 Collection: tangled.GraphFollowNSID, 1605 1634 Repo: user.Did, 1606 1635 Rkey: comment.Rkey, ··· 1647 1676 page = pagination.FirstPage() 1648 1677 } 1649 1678 1650 - user := s.auth.GetUser(r) 1679 + user := s.oauth.GetUser(r) 1651 1680 f, err := s.fullyResolvedRepo(r) 1652 1681 if err != nil { 1653 1682 log.Println("failed to get repo and knot", err) ··· 1676 1705 } 1677 1706 1678 1707 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1679 - LoggedInUser: s.auth.GetUser(r), 1708 + LoggedInUser: s.oauth.GetUser(r), 1680 1709 RepoInfo: f.RepoInfo(s, user), 1681 1710 Issues: issues, 1682 1711 DidHandleMap: didHandleMap, ··· 1687 1716 } 1688 1717 1689 1718 func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1690 - user := s.auth.GetUser(r) 1719 + user := s.oauth.GetUser(r) 1691 1720 1692 1721 f, err := s.fullyResolvedRepo(r) 1693 1722 if err != nil { ··· 1735 1764 return 1736 1765 } 1737 1766 1738 - client, _ := s.auth.AuthorizedClient(r) 1767 + client, err := s.oauth.AuthorizedClient(r) 1768 + if err != nil { 1769 + log.Println("failed to get authorized client", err) 1770 + s.pages.Notice(w, "issues", "Failed to create issue.") 1771 + return 1772 + } 1739 1773 atUri := f.RepoAt.String() 1740 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1774 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1741 1775 Collection: tangled.RepoIssueNSID, 1742 1776 Repo: user.Did, 1743 1777 Rkey: appview.TID(), ··· 1770 1804 } 1771 1805 1772 1806 func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1773 - user := s.auth.GetUser(r) 1807 + user := s.oauth.GetUser(r) 1774 1808 f, err := s.fullyResolvedRepo(r) 1775 1809 if err != nil { 1776 1810 log.Printf("failed to resolve source repo: %v", err) ··· 1779 1813 1780 1814 switch r.Method { 1781 1815 case http.MethodGet: 1782 - user := s.auth.GetUser(r) 1816 + user := s.oauth.GetUser(r) 1783 1817 knots, err := s.enforcer.GetDomainsForUser(user.Did) 1784 1818 if err != nil { 1785 1819 s.pages.Notice(w, "repo", "Invalid user account.") ··· 1829 1863 return 1830 1864 } 1831 1865 1832 - client, err := NewSignedClient(knot, secret, s.config.Dev) 1866 + client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 1833 1867 if err != nil { 1834 1868 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1835 1869 return 1836 1870 } 1837 1871 1838 1872 var uri string 1839 - if s.config.Dev { 1873 + if s.config.Core.Dev { 1840 1874 uri = "http" 1841 1875 } else { 1842 1876 uri = "https" ··· 1883 1917 // continue 1884 1918 } 1885 1919 1886 - xrpcClient, _ := s.auth.AuthorizedClient(r) 1920 + xrpcClient, err := s.oauth.AuthorizedClient(r) 1921 + if err != nil { 1922 + log.Println("failed to get authorized client", err) 1923 + s.pages.Notice(w, "repo", "Failed to create repository.") 1924 + return 1925 + } 1887 1926 1888 1927 createdAt := time.Now().Format(time.RFC3339) 1889 - atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 1928 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1890 1929 Collection: tangled.RepoNSID, 1891 1930 Repo: user.Did, 1892 1931 Rkey: rkey,
+4 -3
appview/state/repo_util.go
··· 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "github.com/go-chi/chi/v5" 14 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/appview/auth" 16 15 "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/knotclient" 17 + "tangled.sh/tangled.sh/core/appview/oauth" 17 18 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 18 19 ) 19 20 ··· 45 46 ref := chi.URLParam(r, "ref") 46 47 47 48 if ref == "" { 48 - us, err := NewUnsignedClient(knot, s.config.Dev) 49 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 49 50 if err != nil { 50 51 return nil, err 51 52 } ··· 73 74 }, nil 74 75 } 75 76 76 - func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo { 77 + func RolesInRepo(s *State, u *oauth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo { 77 78 if u != nil { 78 79 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 79 80 return repoinfo.RolesInRepo{r}
+42 -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) 111 116 }) 112 117 113 118 r.Route("/pulls", func(r chi.Router) { 114 119 r.Get("/", s.RepoPulls) 115 - r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) { 120 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 116 121 r.Get("/", s.NewPull) 117 122 r.Get("/patch-upload", s.PatchUploadFragment) 118 123 r.Post("/validate-patch", s.ValidatePatch) ··· 130 135 r.Get("/", s.RepoPullPatch) 131 136 r.Get("/interdiff", s.RepoPullInterdiff) 132 137 r.Get("/actions", s.PullActions) 133 - r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) { 138 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 134 139 r.Get("/", s.PullComment) 135 140 r.Post("/", s.PullComment) 136 141 }) ··· 141 146 }) 142 147 143 148 r.Group(func(r chi.Router) { 144 - r.Use(middleware.AuthMiddleware(s.auth)) 149 + r.Use(middleware.AuthMiddleware(s.oauth)) 145 150 r.Route("/resubmit", func(r chi.Router) { 146 151 r.Get("/", s.ResubmitPull) 147 152 r.Post("/", s.ResubmitPull) ··· 164 169 165 170 // settings routes, needs auth 166 171 r.Group(func(r chi.Router) { 167 - r.Use(middleware.AuthMiddleware(s.auth)) 172 + r.Use(middleware.AuthMiddleware(s.oauth)) 168 173 // repo description can only be edited by owner 169 174 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 170 175 r.Put("/", s.RepoDescription) ··· 195 200 196 201 r.Get("/", s.Timeline) 197 202 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 - }) 203 + r.With(middleware.AuthMiddleware(s.oauth)).Post("/logout", s.Logout) 204 204 205 205 r.Route("/knots", func(r chi.Router) { 206 - r.Use(middleware.AuthMiddleware(s.auth)) 206 + r.Use(middleware.AuthMiddleware(s.oauth)) 207 207 r.Get("/", s.Knots) 208 208 r.Post("/key", s.RegistrationKey) 209 209 ··· 221 221 222 222 r.Route("/repo", func(r chi.Router) { 223 223 r.Route("/new", func(r chi.Router) { 224 - r.Use(middleware.AuthMiddleware(s.auth)) 224 + r.Use(middleware.AuthMiddleware(s.oauth)) 225 225 r.Get("/", s.NewRepo) 226 226 r.Post("/", s.NewRepo) 227 227 }) 228 228 // r.Post("/import", s.ImportRepo) 229 229 }) 230 230 231 - r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) { 231 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 232 232 r.Post("/", s.Follow) 233 233 r.Delete("/", s.Follow) 234 234 }) 235 235 236 - r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) { 236 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { 237 237 r.Post("/", s.Star) 238 238 r.Delete("/", s.Star) 239 239 }) 240 240 241 - r.Mount("/settings", s.SettingsRouter()) 241 + r.Route("/profile", func(r chi.Router) { 242 + r.Use(middleware.AuthMiddleware(s.oauth)) 243 + r.Get("/edit-bio", s.EditBioFragment) 244 + r.Get("/edit-pins", s.EditPinsFragment) 245 + r.Post("/bio", s.UpdateProfileBio) 246 + r.Post("/pins", s.UpdateProfilePins) 247 + }) 242 248 249 + r.Mount("/settings", s.SettingsRouter()) 250 + r.Mount("/", s.OAuthRouter()) 243 251 r.Get("/keys/{user}", s.Keys) 244 252 245 253 r.NotFound(func(w http.ResponseWriter, r *http.Request) { ··· 248 256 return r 249 257 } 250 258 259 + func (s *State) OAuthRouter() http.Handler { 260 + oauth := &oauthhandler.OAuthHandler{ 261 + Config: s.config, 262 + Pages: s.pages, 263 + Resolver: s.resolver, 264 + Db: s.db, 265 + Store: sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)), 266 + OAuth: s.oauth, 267 + Enforcer: s.enforcer, 268 + } 269 + 270 + return oauth.Router() 271 + } 272 + 251 273 func (s *State) SettingsRouter() http.Handler { 252 274 settings := &settings.Settings{ 253 275 Db: s.db, 254 - Auth: s.auth, 276 + OAuth: s.oauth, 255 277 Pages: s.pages, 256 278 Config: s.config, 257 279 }
-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 - }
+9 -4
appview/state/star.go
··· 15 15 ) 16 16 17 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 18 - currentUser := s.auth.GetUser(r) 18 + currentUser := s.oauth.GetUser(r) 19 19 20 20 subject := r.URL.Query().Get("subject") 21 21 if subject == "" { ··· 29 29 return 30 30 } 31 31 32 - client, _ := s.auth.AuthorizedClient(r) 32 + client, err := s.oauth.AuthorizedClient(r) 33 + if err != nil { 34 + log.Println("failed to authorize client", err) 35 + return 36 + } 33 37 34 38 switch r.Method { 35 39 case http.MethodPost: 36 40 createdAt := time.Now().Format(time.RFC3339) 37 41 rkey := appview.TID() 38 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 42 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 39 43 Collection: tangled.FeedStarNSID, 40 44 Repo: currentUser.Did, 41 45 Rkey: rkey, ··· 80 84 return 81 85 } 82 86 83 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 87 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 84 88 Collection: tangled.FeedStarNSID, 85 89 Repo: currentUser.Did, 86 90 Rkey: star.Rkey, ··· 100 104 starCount, err := db.GetStarCount(s.db, subjectUri) 101 105 if err != nil { 102 106 log.Println("failed to get star count for ", subjectUri) 107 + return 103 108 } 104 109 105 110 s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
+141 -104
appview/state/state.go
··· 19 19 "github.com/go-chi/chi/v5" 20 20 "tangled.sh/tangled.sh/core/api/tangled" 21 21 "tangled.sh/tangled.sh/core/appview" 22 - "tangled.sh/tangled.sh/core/appview/auth" 23 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/knotclient" 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" 26 27 "tangled.sh/tangled.sh/core/rbac" ··· 28 29 29 30 type State struct { 30 31 db *db.DB 31 - auth *auth.Auth 32 + oauth *oauth.OAuth 32 33 enforcer *rbac.Enforcer 33 - tidClock *syntax.TIDClock 34 + tidClock syntax.TIDClock 34 35 pages *pages.Pages 35 36 resolver *appview.Resolver 36 37 jc *jetstream.JetstreamClient ··· 38 39 } 39 40 40 41 func Make(config *appview.Config) (*State, error) { 41 - d, err := db.Make(config.DbPath) 42 - if err != nil { 43 - return nil, err 44 - } 45 - 46 - auth, err := auth.Make(config.CookieSecret) 42 + d, err := db.Make(config.Core.DbPath) 47 43 if err != nil { 48 44 return nil, err 49 45 } 50 46 51 - enforcer, err := rbac.NewEnforcer(config.DbPath) 47 + enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 52 48 if err != nil { 53 49 return nil, err 54 50 } ··· 59 55 60 56 resolver := appview.NewResolver() 61 57 58 + oauth := oauth.NewOAuth(d, config) 59 + 62 60 wrapper := db.DbWrapper{d} 63 61 jc, err := jetstream.NewJetstreamClient( 64 - config.JetstreamEndpoint, 62 + config.Jetstream.Endpoint, 65 63 "appview", 66 - []string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID}, 64 + []string{ 65 + tangled.GraphFollowNSID, 66 + tangled.FeedStarNSID, 67 + tangled.PublicKeyNSID, 68 + tangled.RepoArtifactNSID, 69 + tangled.ActorProfileNSID, 70 + }, 67 71 nil, 68 72 slog.Default(), 69 73 wrapper, ··· 72 76 if err != nil { 73 77 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 74 78 } 75 - err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper)) 79 + err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer)) 76 80 if err != nil { 77 81 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 78 82 } 79 83 80 84 state := &State{ 81 85 d, 82 - auth, 86 + oauth, 83 87 enforcer, 84 88 clock, 85 89 pgs, ··· 95 99 return c.Next().String() 96 100 } 97 101 98 - func (s *State) Login(w http.ResponseWriter, r *http.Request) { 99 - ctx := r.Context() 102 + // func (s *State) Login(w http.ResponseWriter, r *http.Request) { 103 + // ctx := r.Context() 100 104 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 - } 105 + // switch r.Method { 106 + // case http.MethodGet: 107 + // err := s.pages.Login(w, pages.LoginParams{}) 108 + // if err != nil { 109 + // log.Printf("rendering login page: %s", err) 110 + // } 107 111 108 - return 109 - case http.MethodPost: 110 - handle := strings.TrimPrefix(r.FormValue("handle"), "@") 111 - appPassword := r.FormValue("app_password") 112 + // return 113 + // case http.MethodPost: 114 + // handle := strings.TrimPrefix(r.FormValue("handle"), "@") 115 + // appPassword := r.FormValue("app_password") 112 116 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 - } 117 + // resolved, err := s.resolver.ResolveIdent(ctx, handle) 118 + // if err != nil { 119 + // log.Println("failed to resolve handle:", err) 120 + // s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 121 + // return 122 + // } 119 123 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} 124 + // atSession, err := s.oauth.CreateInitialSession(ctx, resolved, appPassword) 125 + // if err != nil { 126 + // s.pages.Notice(w, "login-msg", "Invalid handle or password.") 127 + // return 128 + // } 129 + // sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession} 126 130 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 - } 131 + // err = s.oauth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint()) 132 + // if err != nil { 133 + // s.pages.Notice(w, "login-msg", "Failed to login, try again later.") 134 + // return 135 + // } 132 136 133 - log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 137 + // log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 134 138 135 - did := resolved.DID.String() 136 - defaultKnot := "knot1.tangled.sh" 139 + // did := resolved.DID.String() 140 + // defaultKnot := "knot1.tangled.sh" 137 141 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 - } 142 + // go func() { 143 + // log.Printf("adding %s to default knot", did) 144 + // err = s.enforcer.AddMember(defaultKnot, did) 145 + // if err != nil { 146 + // log.Println("failed to add user to knot1.tangled.sh: ", err) 147 + // return 148 + // } 149 + // err = s.enforcer.E.SavePolicy() 150 + // if err != nil { 151 + // log.Println("failed to add user to knot1.tangled.sh: ", err) 152 + // return 153 + // } 150 154 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 - } 155 + // secret, err := db.GetRegistrationKey(s.db, defaultKnot) 156 + // if err != nil { 157 + // log.Println("failed to get registration key for knot1.tangled.sh") 158 + // return 159 + // } 160 + // signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Core.Dev) 161 + // resp, err := signedClient.AddMember(did) 162 + // if err != nil { 163 + // log.Println("failed to add user to knot1.tangled.sh: ", err) 164 + // return 165 + // } 162 166 163 - if resp.StatusCode != http.StatusNoContent { 164 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 165 - return 166 - } 167 - }() 167 + // if resp.StatusCode != http.StatusNoContent { 168 + // log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 169 + // return 170 + // } 171 + // }() 168 172 169 - s.pages.HxRedirect(w, "/") 170 - return 171 - } 172 - } 173 + // s.pages.HxRedirect(w, "/") 174 + // return 175 + // } 176 + // } 173 177 174 178 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 175 - s.auth.ClearSession(r, w) 179 + s.oauth.ClearSession(r, w) 176 180 w.Header().Set("HX-Redirect", "/login") 177 181 w.WriteHeader(http.StatusSeeOther) 178 182 } 179 183 180 184 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 181 - user := s.auth.GetUser(r) 185 + user := s.oauth.GetUser(r) 182 186 183 187 timeline, err := db.MakeTimeline(s.db) 184 188 if err != nil { ··· 229 233 230 234 return 231 235 case http.MethodPost: 232 - session, err := s.auth.Store.Get(r, appview.SessionName) 236 + session, err := s.oauth.Store.Get(r, appview.SessionName) 233 237 if err != nil || session.IsNew { 234 238 log.Println("unauthorized attempt to generate registration key") 235 239 http.Error(w, "Forbidden", http.StatusUnauthorized) ··· 291 295 292 296 // create a signed request and check if a node responds to that 293 297 func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 294 - user := s.auth.GetUser(r) 298 + user := s.oauth.GetUser(r) 295 299 296 300 domain := chi.URLParam(r, "domain") 297 301 if domain == "" { ··· 306 310 return 307 311 } 308 312 309 - client, err := NewSignedClient(domain, secret, s.config.Dev) 313 + client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 310 314 if err != nil { 311 315 log.Println("failed to create client to ", domain) 312 316 } ··· 415 419 return 416 420 } 417 421 418 - user := s.auth.GetUser(r) 422 + user := s.oauth.GetUser(r) 419 423 reg, err := db.RegistrationByDomain(s.db, domain) 420 424 if err != nil { 421 425 w.Write([]byte("failed to pull up registration info")) ··· 463 467 // get knots registered by this user 464 468 func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 465 469 // for now, this is just pubkeys 466 - user := s.auth.GetUser(r) 470 + user := s.oauth.GetUser(r) 467 471 registrations, err := db.RegistrationsByDid(s.db, user.Did) 468 472 if err != nil { 469 473 log.Println(err) ··· 516 520 log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 517 521 518 522 // announce this relation into the firehose, store into owners' pds 519 - client, _ := s.auth.AuthorizedClient(r) 520 - currentUser := s.auth.GetUser(r) 523 + client, err := s.oauth.AuthorizedClient(r) 524 + if err != nil { 525 + http.Error(w, "failed to authorize client", http.StatusInternalServerError) 526 + return 527 + } 528 + currentUser := s.oauth.GetUser(r) 521 529 createdAt := time.Now().Format(time.RFC3339) 522 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 530 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 523 531 Collection: tangled.KnotMemberNSID, 524 532 Repo: currentUser.Did, 525 533 Rkey: appview.TID(), ··· 544 552 return 545 553 } 546 554 547 - ksClient, err := NewSignedClient(domain, secret, s.config.Dev) 555 + ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 548 556 if err != nil { 549 557 log.Println("failed to create client to ", domain) 550 558 return ··· 573 581 func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 574 582 } 575 583 584 + func validateRepoName(name string) error { 585 + // check for path traversal attempts 586 + if name == "." || name == ".." || 587 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 588 + return fmt.Errorf("Repository name contains invalid path characters") 589 + } 590 + 591 + // check for sequences that could be used for traversal when normalized 592 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 593 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 594 + return fmt.Errorf("Repository name contains invalid path sequence") 595 + } 596 + 597 + // then continue with character validation 598 + for _, char := range name { 599 + if !((char >= 'a' && char <= 'z') || 600 + (char >= 'A' && char <= 'Z') || 601 + (char >= '0' && char <= '9') || 602 + char == '-' || char == '_' || char == '.') { 603 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 604 + } 605 + } 606 + 607 + // additional check to prevent multiple sequential dots 608 + if strings.Contains(name, "..") { 609 + return fmt.Errorf("Repository name cannot contain sequential dots") 610 + } 611 + 612 + // if all checks pass 613 + return nil 614 + } 615 + 576 616 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 577 617 switch r.Method { 578 618 case http.MethodGet: 579 - user := s.auth.GetUser(r) 619 + user := s.oauth.GetUser(r) 580 620 knots, err := s.enforcer.GetDomainsForUser(user.Did) 581 621 if err != nil { 582 622 s.pages.Notice(w, "repo", "Invalid user account.") ··· 589 629 }) 590 630 591 631 case http.MethodPost: 592 - user := s.auth.GetUser(r) 632 + user := s.oauth.GetUser(r) 593 633 594 634 domain := r.FormValue("domain") 595 635 if domain == "" { ··· 603 643 return 604 644 } 605 645 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 - } 646 + if err := validateRepoName(repoName); err != nil { 647 + s.pages.Notice(w, "repo", err.Error()) 648 + return 616 649 } 617 650 618 651 defaultBranch := r.FormValue("branch") ··· 640 673 return 641 674 } 642 675 643 - client, err := NewSignedClient(domain, secret, s.config.Dev) 676 + client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 644 677 if err != nil { 645 678 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 646 679 return ··· 655 688 Description: description, 656 689 } 657 690 658 - xrpcClient, _ := s.auth.AuthorizedClient(r) 691 + xrpcClient, err := s.oauth.AuthorizedClient(r) 692 + if err != nil { 693 + s.pages.Notice(w, "repo", "Failed to write record to PDS.") 694 + return 695 + } 659 696 660 697 createdAt := time.Now().Format(time.RFC3339) 661 - atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 698 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 662 699 Collection: tangled.RepoNSID, 663 700 Repo: user.Did, 664 701 Rkey: rkey,
+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()
+80
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) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 35 + var out atproto.RepoGetRecord_Output 36 + 37 + params := map[string]interface{}{ 38 + "cid": cid, 39 + "collection": collection, 40 + "repo": repo, 41 + "rkey": rkey, 42 + } 43 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 44 + return nil, err 45 + } 46 + 47 + return &out, nil 48 + } 49 + 50 + func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 51 + var out atproto.RepoUploadBlob_Output 52 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + } 58 + 59 + func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 60 + buf := new(bytes.Buffer) 61 + 62 + params := map[string]interface{}{ 63 + "cid": cid, 64 + "did": did, 65 + } 66 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 67 + return nil, err 68 + } 69 + 70 + return buf.Bytes(), nil 71 + } 72 + 73 + func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 74 + var out atproto.RepoDeleteRecord_Output 75 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+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:
+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 }
+12 -9
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-zcfTNo7QsiihzLa4qHEX8uGGtbcmBn8TlSm0YHBRNw8="; 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" ··· 446 450 }; 447 451 }; 448 452 } 449 -
+18 -12
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 ··· 19 19 github.com/go-git/go-git/v5 v5.14.0 20 20 github.com/google/uuid v1.6.0 21 21 github.com/gorilla/sessions v1.4.0 22 + github.com/haileyok/atproto-oauth-golang v0.0.2 22 23 github.com/ipfs/go-cid v0.5.0 24 + github.com/lestrrat-go/jwx/v2 v2.0.12 23 25 github.com/mattn/go-sqlite3 v1.14.24 24 26 github.com/microcosm-cc/bluemonday v1.0.27 25 27 github.com/resend/resend-go/v2 v2.15.0 ··· 41 43 github.com/casbin/govaluate v1.3.0 // indirect 42 44 github.com/cespare/xxhash/v2 v2.3.0 // indirect 43 45 github.com/cloudflare/circl v1.6.0 // indirect 44 - github.com/davecgh/go-spew v1.1.1 // indirect 46 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 45 47 github.com/dlclark/regexp2 v1.11.5 // indirect 46 48 github.com/emirpasic/gods v1.18.1 // indirect 47 49 github.com/felixge/httpsnoop v1.0.4 // indirect 48 50 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 49 51 github.com/go-git/go-billy/v5 v5.6.2 // indirect 50 - github.com/go-logr/logr v1.4.1 // indirect 52 + github.com/go-logr/logr v1.4.2 // indirect 51 53 github.com/go-logr/stdr v1.2.2 // indirect 52 54 github.com/goccy/go-json v0.10.2 // indirect 53 55 github.com/gogo/protobuf v1.3.2 // indirect 56 + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 54 57 github.com/gorilla/css v1.0.1 // indirect 55 58 github.com/gorilla/securecookie v1.1.2 // indirect 56 59 github.com/gorilla/websocket v1.5.1 // indirect ··· 75 78 github.com/kevinburke/ssh_config v1.2.0 // indirect 76 79 github.com/klauspost/compress v1.17.9 // indirect 77 80 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 81 + github.com/lestrrat-go/blackmagic v1.0.2 // indirect 82 + github.com/lestrrat-go/httpcc v1.0.1 // indirect 83 + github.com/lestrrat-go/httprc v1.0.4 // indirect 84 + github.com/lestrrat-go/iter v1.0.2 // indirect 85 + github.com/lestrrat-go/option v1.0.1 // indirect 78 86 github.com/mattn/go-isatty v0.0.20 // indirect 79 87 github.com/minio/sha256-simd v1.0.1 // indirect 80 88 github.com/mr-tron/base58 v1.2.0 // indirect ··· 86 94 github.com/opentracing/opentracing-go v1.2.0 // indirect 87 95 github.com/pjbgf/sha1cd v0.3.2 // indirect 88 96 github.com/pkg/errors v0.9.1 // indirect 89 - github.com/pmezard/go-difflib v1.0.0 // indirect 90 97 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 91 98 github.com/prometheus/client_golang v1.19.1 // indirect 92 99 github.com/prometheus/client_model v0.6.1 // indirect 93 100 github.com/prometheus/common v0.54.0 // indirect 94 101 github.com/prometheus/procfs v0.15.1 // indirect 102 + github.com/segmentio/asm v1.2.0 // indirect 95 103 github.com/sergi/go-diff v1.3.1 // indirect 96 104 github.com/skeema/knownhosts v1.3.1 // indirect 97 105 github.com/spaolacci/murmur3 v1.1.0 // indirect 98 - github.com/stretchr/testify v1.10.0 // indirect 99 106 github.com/xanzy/ssh-agent v0.3.3 // indirect 100 107 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 101 108 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 102 109 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 110 + go.opentelemetry.io/otel v1.29.0 // indirect 111 + go.opentelemetry.io/otel/metric v1.29.0 // indirect 112 + go.opentelemetry.io/otel/trace v1.29.0 // indirect 106 113 go.uber.org/atomic v1.11.0 // indirect 107 114 go.uber.org/multierr v1.11.0 // indirect 108 115 go.uber.org/zap v1.26.0 // indirect 109 116 golang.org/x/crypto v0.37.0 // indirect 110 117 golang.org/x/net v0.39.0 // indirect 111 118 golang.org/x/sys v0.32.0 // indirect 112 - golang.org/x/time v0.5.0 // indirect 119 + golang.org/x/time v0.8.0 // indirect 113 120 google.golang.org/protobuf v1.34.2 // indirect 114 121 gopkg.in/warnings.v0 v0.1.2 // indirect 115 - gopkg.in/yaml.v3 v3.0.1 // indirect 116 122 lukechampine.com/blake3 v1.2.1 // indirect 117 123 ) 118 124
+61 -16
go.sum
··· 26 26 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 27 27 github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI= 28 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= 29 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk= 30 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= 31 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 32 32 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 33 33 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 52 52 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 53 53 github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 54 54 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 55 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 56 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 57 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 58 + github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 59 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 60 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 57 61 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 58 62 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 59 63 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 82 86 github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= 83 87 github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= 84 88 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= 89 + github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 90 + github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 87 91 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 88 92 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 89 93 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= ··· 91 95 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 92 96 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 93 97 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 98 + github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 99 + github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 94 100 github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 95 101 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 96 102 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= ··· 111 117 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 112 118 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 113 119 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 120 + github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 121 + github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 114 122 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 115 123 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 116 124 github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= ··· 159 167 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 160 168 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 161 169 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 170 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 171 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 162 172 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 163 173 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 164 174 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 177 187 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 178 188 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 179 189 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 190 + github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 191 + github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 192 + github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 193 + github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 194 + github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 195 + github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 196 + github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 197 + github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 198 + github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 199 + github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 200 + github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 201 + github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 202 + github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 203 + github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 180 204 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= 181 205 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 182 206 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= ··· 212 236 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 213 237 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 214 238 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 239 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 240 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 241 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 217 242 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 218 243 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 219 244 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= ··· 227 252 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 228 253 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 229 254 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= 255 + github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 256 + github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 232 257 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 258 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 259 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 233 260 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 234 261 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 235 262 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 246 273 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 247 274 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 248 275 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 276 + github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 277 + github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 249 278 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 250 279 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 251 280 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 281 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 252 282 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 283 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 284 + github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 285 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 253 286 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 254 287 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 255 288 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= ··· 270 303 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 271 304 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 272 305 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= 306 + go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 307 + go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 308 + go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 309 + go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 310 + go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 311 + go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 279 312 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 280 313 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 281 314 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= ··· 303 336 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 304 337 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 305 338 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 339 + golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 306 340 golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 307 341 golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 308 342 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= ··· 314 348 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 315 349 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 316 350 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 351 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 317 352 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 318 353 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 319 354 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= ··· 327 362 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 328 363 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 329 364 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 365 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 330 366 golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 331 367 golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 332 368 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 334 370 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 335 371 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 336 372 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 373 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 337 374 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 338 375 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 339 376 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 348 385 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 349 386 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 350 387 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 388 + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 351 389 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 352 390 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 353 391 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 357 395 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 358 396 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 359 397 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 398 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 399 + golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 360 400 golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 361 401 golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 362 402 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= ··· 364 404 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 365 405 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 366 406 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 407 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 408 + golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 367 409 golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 368 410 golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 369 411 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= ··· 372 414 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 373 415 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 374 416 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 417 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 418 + golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 375 419 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 376 420 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= 421 + golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 422 + golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 379 423 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 380 424 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 381 425 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 389 433 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 390 434 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 391 435 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 436 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 392 437 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 393 438 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 394 439 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+38
knotserver/routes.go
··· 600 600 name := data.Name 601 601 defaultBranch := data.DefaultBranch 602 602 603 + if err := validateRepoName(name); err != nil { 604 + l.Error("creating repo", "error", err.Error()) 605 + writeError(w, err.Error(), http.StatusBadRequest) 606 + return 607 + } 608 + 603 609 relativeRepoPath := filepath.Join(did, name) 604 610 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 605 611 err := git.InitBare(repoPath, defaultBranch) ··· 1078 1084 func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1079 1085 w.Write([]byte("ok")) 1080 1086 } 1087 + 1088 + func validateRepoName(name string) error { 1089 + // check for path traversal attempts 1090 + if name == "." || name == ".." || 1091 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 1092 + return fmt.Errorf("Repository name contains invalid path characters") 1093 + } 1094 + 1095 + // check for sequences that could be used for traversal when normalized 1096 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 1097 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1098 + return fmt.Errorf("Repository name contains invalid path sequence") 1099 + } 1100 + 1101 + // then continue with character validation 1102 + for _, char := range name { 1103 + if !((char >= 'a' && char <= 'z') || 1104 + (char >= 'A' && char <= 'Z') || 1105 + (char >= '0' && char <= '9') || 1106 + char == '-' || char == '_' || char == '.') { 1107 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1108 + } 1109 + } 1110 + 1111 + // additional check to prevent multiple sequential dots 1112 + if strings.Contains(name, "..") { 1113 + return fmt.Errorf("Repository name cannot contain sequential dots") 1114 + } 1115 + 1116 + // if all checks pass 1117 + return nil 1118 + }
+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": {
+5
scripts/generate-jwks.sh
··· 1 + #! /usr/bin/env bash 2 + 3 + set -e 4 + 5 + go run ./cmd/genjwks/