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

Compare changes

Choose any two refs to compare.

Changed files
+7908 -2185
.tangled
workflows
api
appview
avatar
src
cmd
docs
guard
hook
idresolver
jetstream
knotserver
lexicons
nix
rbac
spindle
+2 -1
.tangled/workflows/fmt.yml
··· 14 14 15 15 - name: "go fmt" 16 16 command: | 17 - gofmt -l . 17 + unformatted=$(gofmt -l .) 18 + test -z "$unformatted" || (echo "$unformatted" && exit 1) 18 19
+198
api/tangled/cbor_gen.go
··· 5854 5854 5855 5855 return nil 5856 5856 } 5857 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5858 + if t == nil { 5859 + _, err := w.Write(cbg.CborNull) 5860 + return err 5861 + } 5862 + 5863 + cw := cbg.NewCborWriter(w) 5864 + 5865 + if _, err := cw.Write([]byte{164}); err != nil { 5866 + return err 5867 + } 5868 + 5869 + // t.Repo (string) (string) 5870 + if len("repo") > 1000000 { 5871 + return xerrors.Errorf("Value in field \"repo\" was too long") 5872 + } 5873 + 5874 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5875 + return err 5876 + } 5877 + if _, err := cw.WriteString(string("repo")); err != nil { 5878 + return err 5879 + } 5880 + 5881 + if len(t.Repo) > 1000000 { 5882 + return xerrors.Errorf("Value in field t.Repo was too long") 5883 + } 5884 + 5885 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5886 + return err 5887 + } 5888 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5889 + return err 5890 + } 5891 + 5892 + // t.LexiconTypeID (string) (string) 5893 + if len("$type") > 1000000 { 5894 + return xerrors.Errorf("Value in field \"$type\" was too long") 5895 + } 5896 + 5897 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5898 + return err 5899 + } 5900 + if _, err := cw.WriteString(string("$type")); err != nil { 5901 + return err 5902 + } 5903 + 5904 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5905 + return err 5906 + } 5907 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5908 + return err 5909 + } 5910 + 5911 + // t.Subject (string) (string) 5912 + if len("subject") > 1000000 { 5913 + return xerrors.Errorf("Value in field \"subject\" was too long") 5914 + } 5915 + 5916 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5917 + return err 5918 + } 5919 + if _, err := cw.WriteString(string("subject")); err != nil { 5920 + return err 5921 + } 5922 + 5923 + if len(t.Subject) > 1000000 { 5924 + return xerrors.Errorf("Value in field t.Subject was too long") 5925 + } 5926 + 5927 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5928 + return err 5929 + } 5930 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5931 + return err 5932 + } 5933 + 5934 + // t.CreatedAt (string) (string) 5935 + if len("createdAt") > 1000000 { 5936 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5937 + } 5938 + 5939 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5940 + return err 5941 + } 5942 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5943 + return err 5944 + } 5945 + 5946 + if len(t.CreatedAt) > 1000000 { 5947 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5948 + } 5949 + 5950 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5951 + return err 5952 + } 5953 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5954 + return err 5955 + } 5956 + return nil 5957 + } 5958 + 5959 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5960 + *t = RepoCollaborator{} 5961 + 5962 + cr := cbg.NewCborReader(r) 5963 + 5964 + maj, extra, err := cr.ReadHeader() 5965 + if err != nil { 5966 + return err 5967 + } 5968 + defer func() { 5969 + if err == io.EOF { 5970 + err = io.ErrUnexpectedEOF 5971 + } 5972 + }() 5973 + 5974 + if maj != cbg.MajMap { 5975 + return fmt.Errorf("cbor input should be of type map") 5976 + } 5977 + 5978 + if extra > cbg.MaxLength { 5979 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5980 + } 5981 + 5982 + n := extra 5983 + 5984 + nameBuf := make([]byte, 9) 5985 + for i := uint64(0); i < n; i++ { 5986 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5987 + if err != nil { 5988 + return err 5989 + } 5990 + 5991 + if !ok { 5992 + // Field doesn't exist on this type, so ignore it 5993 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5994 + return err 5995 + } 5996 + continue 5997 + } 5998 + 5999 + switch string(nameBuf[:nameLen]) { 6000 + // t.Repo (string) (string) 6001 + case "repo": 6002 + 6003 + { 6004 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6005 + if err != nil { 6006 + return err 6007 + } 6008 + 6009 + t.Repo = string(sval) 6010 + } 6011 + // t.LexiconTypeID (string) (string) 6012 + case "$type": 6013 + 6014 + { 6015 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6016 + if err != nil { 6017 + return err 6018 + } 6019 + 6020 + t.LexiconTypeID = string(sval) 6021 + } 6022 + // t.Subject (string) (string) 6023 + case "subject": 6024 + 6025 + { 6026 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6027 + if err != nil { 6028 + return err 6029 + } 6030 + 6031 + t.Subject = string(sval) 6032 + } 6033 + // t.CreatedAt (string) (string) 6034 + case "createdAt": 6035 + 6036 + { 6037 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6038 + if err != nil { 6039 + return err 6040 + } 6041 + 6042 + t.CreatedAt = string(sval) 6043 + } 6044 + 6045 + default: 6046 + // Field doesn't exist on this type, so ignore it 6047 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6048 + return err 6049 + } 6050 + } 6051 + } 6052 + 6053 + return nil 6054 + } 5857 6055 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5858 6056 if t == nil { 5859 6057 _, err := w.Write(cbg.CborNull)
+31
api/tangled/repoaddSecret.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.addSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoAddSecretNSID = "sh.tangled.repo.addSecret" 15 + ) 16 + 17 + // RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call. 18 + type RepoAddSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + Value string `json:"value" cborgen:"value"` 22 + } 23 + 24 + // RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret". 25 + func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error { 26 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil { 27 + return err 28 + } 29 + 30 + return nil 31 + }
+25
api/tangled/repocollaborator.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.collaborator 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoCollaboratorNSID = "sh.tangled.repo.collaborator" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) 17 + } // 18 + // RECORDTYPE: RepoCollaborator 19 + type RepoCollaborator struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // repo: repo to add this user to 23 + Repo string `json:"repo" cborgen:"repo"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 + }
+41
api/tangled/repolistSecrets.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.listSecrets 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoListSecretsNSID = "sh.tangled.repo.listSecrets" 15 + ) 16 + 17 + // RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call. 18 + type RepoListSecrets_Output struct { 19 + Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"` 20 + } 21 + 22 + // RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema. 23 + type RepoListSecrets_Secret struct { 24 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 25 + CreatedBy string `json:"createdBy" cborgen:"createdBy"` 26 + Key string `json:"key" cborgen:"key"` 27 + Repo string `json:"repo" cborgen:"repo"` 28 + } 29 + 30 + // RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets". 31 + func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) { 32 + var out RepoListSecrets_Output 33 + 34 + params := map[string]interface{}{} 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil { 37 + return nil, err 38 + } 39 + 40 + return &out, nil 41 + }
+30
api/tangled/reporemoveSecret.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.removeSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret" 15 + ) 16 + 17 + // RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call. 18 + type RepoRemoveSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret". 24 + func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+30
api/tangled/reposetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.setDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch" 15 + ) 16 + 17 + // RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call. 18 + type RepoSetDefaultBranch_Input struct { 19 + DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch". 24 + func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+3 -1
api/tangled/stateclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.closed 6 6 7 - const () 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 8 10 9 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.open 6 6 7 - const () 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 8 10 9 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.closed 6 6 7 - const () 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 8 10 9 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.merged 6 6 7 - const () 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 8 10 9 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.open 6 6 7 - const () 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 8 10 9 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+18 -5
appview/config/config.go
··· 10 10 ) 11 11 12 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 18 19 } 19 20 20 21 type OAuthConfig struct { ··· 59 60 DB int `env:"DB, default=0"` 60 61 } 61 62 63 + type PdsConfig struct { 64 + Host string `env:"HOST, default=https://tngl.sh"` 65 + AdminSecret string `env:"ADMIN_SECRET"` 66 + } 67 + 68 + type Cloudflare struct { 69 + ApiToken string `env:"API_TOKEN"` 70 + ZoneId string `env:"ZONE_ID"` 71 + } 72 + 62 73 func (cfg RedisConfig) ToURL() string { 63 74 u := &url.URL{ 64 75 Scheme: "redis", ··· 84 95 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 96 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 97 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 98 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 99 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 100 } 88 101 89 102 func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + type Collaborator struct { 12 + // identifiers for the record 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + 17 + // content 18 + SubjectDid syntax.DID 19 + RepoAt syntax.ATURI 20 + 21 + // meta 22 + Created time.Time 23 + } 24 + 25 + func AddCollaborator(e Execer, c Collaborator) error { 26 + _, err := e.Exec( 27 + `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 + c.Did, c.Rkey, c.SubjectDid, c.RepoAt, 29 + ) 30 + return err 31 + } 32 + 33 + func DeleteCollaborator(e Execer, filters ...filter) error { 34 + var conditions []string 35 + var args []any 36 + for _, filter := range filters { 37 + conditions = append(conditions, filter.Condition()) 38 + args = append(args, filter.Arg()...) 39 + } 40 + 41 + whereClause := "" 42 + if conditions != nil { 43 + whereClause = " where " + strings.Join(conditions, " and ") 44 + } 45 + 46 + query := fmt.Sprintf(`delete from collaborators %s`, whereClause) 47 + 48 + _, err := e.Exec(query, args...) 49 + return err 50 + } 51 + 52 + func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 + rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer rows.Close() 58 + 59 + var repoAts []string 60 + for rows.Next() { 61 + var aturi string 62 + err := rows.Scan(&aturi) 63 + if err != nil { 64 + return nil, err 65 + } 66 + repoAts = append(repoAts, aturi) 67 + } 68 + if err := rows.Err(); err != nil { 69 + return nil, err 70 + } 71 + if repoAts == nil { 72 + return nil, nil 73 + } 74 + 75 + return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 76 + }
+65 -3
appview/db/db.go
··· 355 355 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 356 356 357 357 -- constraints 358 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 359 358 unique (did, instance, subject) 360 359 ); 361 360 ··· 435 434 bytes integer not null check (bytes >= 0), 436 435 437 436 unique(repo_at, ref, language) 437 + ); 438 + 439 + create table if not exists signups_inflight ( 440 + id integer primary key autoincrement, 441 + email text not null unique, 442 + invite_code text not null, 443 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 438 444 ); 439 445 440 446 create table if not exists migrations ( ··· 579 585 return nil 580 586 }) 581 587 588 + // recreate and add rkey + created columns with default constraint 589 + runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 590 + // create new table 591 + // - repo_at instead of repo integer 592 + // - rkey field 593 + // - created field 594 + _, err := tx.Exec(` 595 + create table collaborators_new ( 596 + -- identifiers for the record 597 + id integer primary key autoincrement, 598 + did text not null, 599 + rkey text, 600 + 601 + -- content 602 + subject_did text not null, 603 + repo_at text not null, 604 + 605 + -- meta 606 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 607 + 608 + -- constraints 609 + foreign key (repo_at) references repos(at_uri) on delete cascade 610 + ) 611 + `) 612 + if err != nil { 613 + return err 614 + } 615 + 616 + // copy data 617 + _, err = tx.Exec(` 618 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 619 + select 620 + c.id, 621 + r.did, 622 + '', 623 + c.did, 624 + r.at_uri 625 + from collaborators c 626 + join repos r on c.repo = r.id 627 + `) 628 + if err != nil { 629 + return err 630 + } 631 + 632 + // drop old table 633 + _, err = tx.Exec(`drop table collaborators`) 634 + if err != nil { 635 + return err 636 + } 637 + 638 + // rename new table 639 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 640 + return err 641 + }) 642 + 582 643 return &DB{db}, nil 583 644 } 584 645 ··· 654 715 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 655 716 if kind == reflect.Slice || kind == reflect.Array { 656 717 if rv.Len() == 0 { 657 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 718 + // always false 719 + return "1 = 0" 658 720 } 659 721 660 722 placeholders := make([]string, rv.Len()) ··· 673 735 kind := rv.Kind() 674 736 if kind == reflect.Slice || kind == reflect.Array { 675 737 if rv.Len() == 0 { 676 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 738 + return nil 677 739 } 678 740 679 741 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 103 query := ` 104 104 select email, did 105 105 from emails 106 - where 107 - verified = ? 106 + where 107 + verified = ? 108 108 and email in (` + strings.Join(placeholders, ",") + `) 109 109 ` 110 110 ··· 153 153 ` 154 154 var count int 155 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 161 + 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 156 170 if err != nil { 157 171 return false, err 158 172 }
+2 -2
appview/db/follow.go
··· 12 12 Rkey string 13 13 } 14 14 15 - func AddFollow(e Execer, userDid, subjectDid, rkey string) error { 15 + func AddFollow(e Execer, follow *Follow) error { 16 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 - _, err := e.Exec(query, userDid, subjectDid, rkey) 17 + _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 18 18 return err 19 19 } 20 20
+17 -12
appview/db/issues.go
··· 9 9 ) 10 10 11 11 type Issue struct { 12 + ID int64 12 13 RepoAt syntax.ATURI 13 14 OwnerDid string 14 15 IssueId int ··· 65 66 66 67 issue.IssueId = nextId 67 68 68 - _, err = tx.Exec(` 69 + res, err := tx.Exec(` 69 70 insert into issues (repo_at, owner_did, issue_id, title, body) 70 71 values (?, ?, ?, ?, ?) 71 72 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 72 73 if err != nil { 73 74 return err 74 75 } 76 + 77 + lastID, err := res.LastInsertId() 78 + if err != nil { 79 + return err 80 + } 81 + issue.ID = lastID 75 82 76 83 if err := tx.Commit(); err != nil { 77 84 return err ··· 89 96 var issueAt string 90 97 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 98 return issueAt, err 92 - } 93 - 94 - func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) { 95 - var issueId int 96 - err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId) 97 - return issueId - 1, err 98 99 } 99 100 100 101 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 114 115 ` 115 116 with numbered_issue as ( 116 117 select 118 + i.id, 117 119 i.owner_did, 118 120 i.issue_id, 119 121 i.created, ··· 132 134 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 135 ) 134 136 select 137 + id, 135 138 owner_did, 136 139 issue_id, 137 140 created, ··· 153 156 var issue Issue 154 157 var createdAt string 155 158 var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 159 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 160 if err != nil { 158 161 return nil, err 159 162 } ··· 182 185 183 186 rows, err := e.Query( 184 187 `select 188 + i.id, 185 189 i.owner_did, 186 190 i.repo_at, 187 191 i.issue_id, ··· 213 217 var issueCreatedAt, repoCreatedAt string 214 218 var repo Repo 215 219 err := rows.Scan( 220 + &issue.ID, 216 221 &issue.OwnerDid, 217 222 &issue.RepoAt, 218 223 &issue.IssueId, ··· 257 262 } 258 263 259 264 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 260 - query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 265 + query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 261 266 row := e.QueryRow(query, repoAt, issueId) 262 267 263 268 var issue Issue 264 269 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 270 + err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 271 if err != nil { 267 272 return nil, err 268 273 } ··· 277 282 } 278 283 279 284 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 280 - query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 285 + query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 281 286 row := e.QueryRow(query, repoAt, issueId) 282 287 283 288 var issue Issue 284 289 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 290 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 286 291 if err != nil { 287 292 return nil, nil, err 288 293 }
+1 -34
appview/db/repos.go
··· 106 106 from 107 107 repos r 108 108 %s 109 + order by created desc 109 110 %s`, 110 111 whereClause, 111 112 limitClause, ··· 549 550 return &repo, nil 550 551 } 551 552 552 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 553 - _, err := e.Exec( 554 - `insert into collaborators (did, repo) 555 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 556 - collaborator, repoOwnerDid, repoName, repoKnot) 557 - return err 558 - } 559 - 560 553 func UpdateDescription(e Execer, repoAt, newDescription string) error { 561 554 _, err := e.Exec( 562 555 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) ··· 567 560 _, err := e.Exec( 568 561 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 569 562 return err 570 - } 571 - 572 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 573 - rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator) 574 - if err != nil { 575 - return nil, err 576 - } 577 - defer rows.Close() 578 - 579 - var repoIds []int 580 - for rows.Next() { 581 - var id int 582 - err := rows.Scan(&id) 583 - if err != nil { 584 - return nil, err 585 - } 586 - repoIds = append(repoIds, id) 587 - } 588 - if err := rows.Err(); err != nil { 589 - return nil, err 590 - } 591 - if repoIds == nil { 592 - return nil, nil 593 - } 594 - 595 - return GetRepos(e, 0, FilterIn("id", repoIds)) 596 563 } 597 564 598 565 type RepoStats struct {
+29
appview/db/signup.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + } 11 + 12 + func AddInflightSignup(e Execer, signup InflightSignup) error { 13 + query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 + _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 + return err 16 + } 17 + 18 + func DeleteInflightSignup(e Execer, email string) error { 19 + query := `delete from signups_inflight where email = ?` 20 + _, err := e.Exec(query, email) 21 + return err 22 + } 23 + 24 + func GetEmailForCode(e Execer, inviteCode string) (string, error) { 25 + query := `select email from signups_inflight where invite_code = ?` 26 + var email string 27 + err := e.QueryRow(query, inviteCode).Scan(&email) 28 + return email, err 29 + }
+7 -2
appview/db/star.go
··· 33 33 return nil 34 34 } 35 35 36 - func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error { 36 + func AddStar(e Execer, star *Star) error { 37 37 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 38 - _, err := e.Exec(query, starredByDid, repoAt, rkey) 38 + _, err := e.Exec( 39 + query, 40 + star.StarredByDid, 41 + star.RepoAt.String(), 42 + star.Rkey, 43 + ) 39 44 return err 40 45 } 41 46
+2 -5
appview/db/timeline.go
··· 174 174 175 175 var events []TimelineEvent 176 176 for _, f := range follows { 177 - profile, ok1 := profileMap[f.SubjectDid] 178 - followStatMap, ok2 := followStatMap[f.SubjectDid] 179 - if !ok1 || !ok2 { 180 - continue 181 - } 177 + profile, _ := profileMap[f.SubjectDid] 178 + followStatMap, _ := followStatMap[f.SubjectDid] 182 179 183 180 events = append(events, TimelineEvent{ 184 181 Follow: &f,
+53
appview/dns/cloudflare.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/cloudflare/cloudflare-go" 8 + "tangled.sh/tangled.sh/core/appview/config" 9 + ) 10 + 11 + type Record struct { 12 + Type string 13 + Name string 14 + Content string 15 + TTL int 16 + Proxied bool 17 + } 18 + 19 + type Cloudflare struct { 20 + api *cloudflare.API 21 + zone string 22 + } 23 + 24 + func NewCloudflare(c *config.Config) (*Cloudflare, error) { 25 + apiToken := c.Cloudflare.ApiToken 26 + api, err := cloudflare.NewWithAPIToken(apiToken) 27 + if err != nil { 28 + return nil, err 29 + } 30 + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 + } 32 + 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 + Type: record.Type, 36 + Name: record.Name, 37 + Content: record.Content, 38 + TTL: record.TTL, 39 + Proxied: &record.Proxied, 40 + }) 41 + if err != nil { 42 + return fmt.Errorf("failed to create DNS record: %w", err) 43 + } 44 + return nil 45 + } 46 + 47 + func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { 48 + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) 49 + if err != nil { 50 + return fmt.Errorf("failed to delete DNS record: %w", err) 51 + } 52 + return nil 53 + }
-113
appview/idresolver/resolver.go
··· 1 - package idresolver 2 - 3 - import ( 4 - "context" 5 - "net" 6 - "net/http" 7 - "sync" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "github.com/carlmjohnson/versioninfo" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 - ) 16 - 17 - type Resolver struct { 18 - directory identity.Directory 19 - } 20 - 21 - func BaseDirectory() identity.Directory { 22 - base := identity.BaseDirectory{ 23 - PLCURL: identity.DefaultPLCURL, 24 - HTTPClient: http.Client{ 25 - Timeout: time.Second * 10, 26 - Transport: &http.Transport{ 27 - // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 28 - IdleConnTimeout: time.Millisecond * 1000, 29 - MaxIdleConns: 100, 30 - }, 31 - }, 32 - Resolver: net.Resolver{ 33 - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 34 - d := net.Dialer{Timeout: time.Second * 3} 35 - return d.DialContext(ctx, network, address) 36 - }, 37 - }, 38 - TryAuthoritativeDNS: true, 39 - // primary Bluesky PDS instance only supports HTTP resolution method 40 - SkipDNSDomainSuffixes: []string{".bsky.social"}, 41 - UserAgent: "indigo-identity/" + versioninfo.Short(), 42 - } 43 - return &base 44 - } 45 - 46 - func RedisDirectory(url string) (identity.Directory, error) { 47 - hitTTL := time.Hour * 24 48 - errTTL := time.Second * 30 49 - invalidHandleTTL := time.Minute * 5 50 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 51 - } 52 - 53 - func DefaultResolver() *Resolver { 54 - return &Resolver{ 55 - directory: identity.DefaultDirectory(), 56 - } 57 - } 58 - 59 - func RedisResolver(config config.RedisConfig) (*Resolver, error) { 60 - directory, err := RedisDirectory(config.ToURL()) 61 - if err != nil { 62 - return nil, err 63 - } 64 - return &Resolver{ 65 - directory: directory, 66 - }, nil 67 - } 68 - 69 - func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 70 - id, err := syntax.ParseAtIdentifier(arg) 71 - if err != nil { 72 - return nil, err 73 - } 74 - 75 - return r.directory.Lookup(ctx, *id) 76 - } 77 - 78 - func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 79 - results := make([]*identity.Identity, len(idents)) 80 - var wg sync.WaitGroup 81 - 82 - done := make(chan struct{}) 83 - defer close(done) 84 - 85 - for idx, ident := range idents { 86 - wg.Add(1) 87 - go func(index int, id string) { 88 - defer wg.Done() 89 - 90 - select { 91 - case <-ctx.Done(): 92 - results[index] = nil 93 - case <-done: 94 - results[index] = nil 95 - default: 96 - identity, _ := r.ResolveIdent(ctx, id) 97 - results[index] = identity 98 - } 99 - }(idx, ident) 100 - } 101 - 102 - wg.Wait() 103 - return results 104 - } 105 - 106 - func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 107 - id, err := syntax.ParseAtIdentifier(arg) 108 - if err != nil { 109 - return err 110 - } 111 - 112 - return r.directory.Purge(ctx, *id) 113 - }
+21 -4
appview/ingester.go
··· 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 15 "tangled.sh/tangled.sh/core/appview/config" 16 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 17 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/rbac" 20 20 ) 21 21 ··· 100 100 l.Error("invalid record", "err", err) 101 101 return err 102 102 } 103 - err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey) 103 + err = db.AddStar(i.Db, &db.Star{ 104 + StarredByDid: did, 105 + RepoAt: subjectUri, 106 + Rkey: e.Commit.RKey, 107 + }) 104 108 case models.CommitOperationDelete: 105 109 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 106 110 } ··· 129 133 return err 130 134 } 131 135 132 - subjectDid := record.Subject 133 - err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey) 136 + err = db.AddFollow(i.Db, &db.Follow{ 137 + UserDid: did, 138 + SubjectDid: record.Subject, 139 + Rkey: e.Commit.RKey, 140 + }) 134 141 case models.CommitOperationDelete: 135 142 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 136 143 } ··· 502 509 tx.Rollback() 503 510 i.Enforcer.E.LoadPolicy() 504 511 }() 512 + 513 + // remove spindle members first 514 + err = db.RemoveSpindleMember( 515 + tx, 516 + db.FilterEq("owner", did), 517 + db.FilterEq("instance", instance), 518 + ) 519 + if err != nil { 520 + return err 521 + } 505 522 506 523 err = db.DeleteSpindle( 507 524 tx,
+18 -33
appview/issues/issues.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 - "github.com/posthog/posthog-go" 18 17 19 18 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview" 21 19 "tangled.sh/tangled.sh/core/appview/config" 22 20 "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 + "tangled.sh/tangled.sh/core/appview/notify" 24 22 "tangled.sh/tangled.sh/core/appview/oauth" 25 23 "tangled.sh/tangled.sh/core/appview/pages" 26 24 "tangled.sh/tangled.sh/core/appview/pagination" 27 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/idresolver" 27 + "tangled.sh/tangled.sh/core/tid" 28 28 ) 29 29 30 30 type Issues struct { ··· 34 34 idResolver *idresolver.Resolver 35 35 db *db.DB 36 36 config *config.Config 37 - posthog posthog.Client 37 + notifier notify.Notifier 38 38 } 39 39 40 40 func New( ··· 44 44 idResolver *idresolver.Resolver, 45 45 db *db.DB, 46 46 config *config.Config, 47 - posthog posthog.Client, 47 + notifier notify.Notifier, 48 48 ) *Issues { 49 49 return &Issues{ 50 50 oauth: oauth, ··· 53 53 idResolver: idResolver, 54 54 db: db, 55 55 config: config, 56 - posthog: posthog, 56 + notifier: notifier, 57 57 } 58 58 } 59 59 ··· 120 120 DidHandleMap: didHandleMap, 121 121 122 122 OrderedReactionKinds: db.OrderedReactionKinds, 123 - Reactions: reactionCountMap, 124 - UserReacted: userReactions, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 125 125 }) 126 126 127 127 } ··· 171 171 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 172 Collection: tangled.RepoIssueStateNSID, 173 173 Repo: user.Did, 174 - Rkey: appview.TID(), 174 + Rkey: tid.TID(), 175 175 Record: &lexutil.LexiconTypeDecoder{ 176 176 Val: &tangled.RepoIssueState{ 177 177 Issue: issue.IssueAt, ··· 275 275 } 276 276 277 277 commentId := mathrand.IntN(1000000) 278 - rkey := appview.TID() 278 + rkey := tid.TID() 279 279 280 280 err := db.NewIssueComment(rp.db, &db.Comment{ 281 281 OwnerDid: user.Did, ··· 703 703 return 704 704 } 705 705 706 - err = db.NewIssue(tx, &db.Issue{ 706 + issue := &db.Issue{ 707 707 RepoAt: f.RepoAt, 708 708 Title: title, 709 709 Body: body, 710 710 OwnerDid: user.Did, 711 - }) 712 - if err != nil { 713 - log.Println("failed to create issue", err) 714 - rp.pages.Notice(w, "issues", "Failed to create issue.") 715 - return 716 711 } 717 - 718 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 712 + err = db.NewIssue(tx, issue) 719 713 if err != nil { 720 - log.Println("failed to get issue id", err) 714 + log.Println("failed to create issue", err) 721 715 rp.pages.Notice(w, "issues", "Failed to create issue.") 722 716 return 723 717 } ··· 732 726 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 733 727 Collection: tangled.RepoIssueNSID, 734 728 Repo: user.Did, 735 - Rkey: appview.TID(), 729 + Rkey: tid.TID(), 736 730 Record: &lexutil.LexiconTypeDecoder{ 737 731 Val: &tangled.RepoIssue{ 738 732 Repo: atUri, 739 733 Title: title, 740 734 Body: &body, 741 735 Owner: user.Did, 742 - IssueId: int64(issueId), 736 + IssueId: int64(issue.IssueId), 743 737 }, 744 738 }, 745 739 }) ··· 749 743 return 750 744 } 751 745 752 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 746 + err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 753 747 if err != nil { 754 748 log.Println("failed to set issue at", err) 755 749 rp.pages.Notice(w, "issues", "Failed to create issue.") 756 750 return 757 751 } 758 752 759 - if !rp.config.Core.Dev { 760 - err = rp.posthog.Enqueue(posthog.Capture{ 761 - DistinctId: user.Did, 762 - Event: "new_issue", 763 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 764 - }) 765 - if err != nil { 766 - log.Println("failed to enqueue posthog event:", err) 767 - } 768 - } 753 + rp.notifier.NewIssue(r.Context(), issue) 769 754 770 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 755 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 771 756 return 772 757 } 773 758 }
+3 -4
appview/knots/knots.go
··· 13 13 14 14 "github.com/go-chi/chi/v5" 15 15 "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/appview" 17 16 "tangled.sh/tangled.sh/core/appview/config" 18 17 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 18 "tangled.sh/tangled.sh/core/appview/middleware" 21 19 "tangled.sh/tangled.sh/core/appview/oauth" 22 20 "tangled.sh/tangled.sh/core/appview/pages" 23 21 "tangled.sh/tangled.sh/core/eventconsumer" 22 + "tangled.sh/tangled.sh/core/idresolver" 24 23 "tangled.sh/tangled.sh/core/knotclient" 25 24 "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/tid" 26 26 27 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 28 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 378 378 } 379 379 380 380 w.Write([]byte(strings.Join(memberDids, "\n"))) 381 - return 382 381 } 383 382 384 383 // add member to domain, requires auth and requires invite access ··· 436 435 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 437 436 Collection: tangled.KnotMemberNSID, 438 437 Repo: currentUser.Did, 439 - Rkey: appview.TID(), 438 + Rkey: tid.TID(), 440 439 Record: &lexutil.LexiconTypeDecoder{ 441 440 Val: &tangled.KnotMember{ 442 441 Subject: subjectIdentity.DID.String(),
+1 -1
appview/middleware/middleware.go
··· 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 15 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 16 "tangled.sh/tangled.sh/core/appview/oauth" 18 17 "tangled.sh/tangled.sh/core/appview/pages" 19 18 "tangled.sh/tangled.sh/core/appview/pagination" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/rbac" 22 22 ) 23 23
+68
appview/notify/merged_notifier.go
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.sh/tangled.sh/core/appview/db" 7 + ) 8 + 9 + type mergedNotifier struct { 10 + notifiers []Notifier 11 + } 12 + 13 + func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 + return &mergedNotifier{notifiers} 15 + } 16 + 17 + var _ Notifier = &mergedNotifier{} 18 + 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 20 + for _, notifier := range m.notifiers { 21 + notifier.NewRepo(ctx, repo) 22 + } 23 + } 24 + 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 26 + for _, notifier := range m.notifiers { 27 + notifier.NewStar(ctx, star) 28 + } 29 + } 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 31 + for _, notifier := range m.notifiers { 32 + notifier.DeleteStar(ctx, star) 33 + } 34 + } 35 + 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 37 + for _, notifier := range m.notifiers { 38 + notifier.NewIssue(ctx, issue) 39 + } 40 + } 41 + 42 + func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 43 + for _, notifier := range m.notifiers { 44 + notifier.NewFollow(ctx, follow) 45 + } 46 + } 47 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 48 + for _, notifier := range m.notifiers { 49 + notifier.DeleteFollow(ctx, follow) 50 + } 51 + } 52 + 53 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 54 + for _, notifier := range m.notifiers { 55 + notifier.NewPull(ctx, pull) 56 + } 57 + } 58 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 59 + for _, notifier := range m.notifiers { 60 + notifier.NewPullComment(ctx, comment) 61 + } 62 + } 63 + 64 + func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 65 + for _, notifier := range m.notifiers { 66 + notifier.UpdateProfile(ctx, profile) 67 + } 68 + }
+44
appview/notify/notifier.go
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.sh/tangled.sh/core/appview/db" 7 + ) 8 + 9 + type Notifier interface { 10 + NewRepo(ctx context.Context, repo *db.Repo) 11 + 12 + NewStar(ctx context.Context, star *db.Star) 13 + DeleteStar(ctx context.Context, star *db.Star) 14 + 15 + NewIssue(ctx context.Context, issue *db.Issue) 16 + 17 + NewFollow(ctx context.Context, follow *db.Follow) 18 + DeleteFollow(ctx context.Context, follow *db.Follow) 19 + 20 + NewPull(ctx context.Context, pull *db.Pull) 21 + NewPullComment(ctx context.Context, comment *db.PullComment) 22 + 23 + UpdateProfile(ctx context.Context, profile *db.Profile) 24 + } 25 + 26 + // BaseNotifier is a listener that does nothing 27 + type BaseNotifier struct{} 28 + 29 + var _ Notifier = &BaseNotifier{} 30 + 31 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 32 + 33 + func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 34 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 35 + 36 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 37 + 38 + func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 39 + func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 40 + 41 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 42 + func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 43 + 44 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+1 -1
appview/oauth/handler/handler.go
··· 16 16 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 17 "tangled.sh/tangled.sh/core/appview/config" 18 18 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 19 "tangled.sh/tangled.sh/core/appview/middleware" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/rbac" 26 26 )
+73
appview/oauth/oauth.go
··· 7 7 "net/url" 8 8 "time" 9 9 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 10 11 "github.com/gorilla/sessions" 11 12 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 13 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 204 205 }) 205 206 206 207 return xrpcClient, nil 208 + } 209 + 210 + // use this to create a client to communicate with knots or spindles 211 + // 212 + // this is a higher level abstraction on ServerGetServiceAuth 213 + type ServiceClientOpts struct { 214 + service string 215 + exp int64 216 + lxm string 217 + dev bool 218 + } 219 + 220 + type ServiceClientOpt func(*ServiceClientOpts) 221 + 222 + func WithService(service string) ServiceClientOpt { 223 + return func(s *ServiceClientOpts) { 224 + s.service = service 225 + } 226 + } 227 + func WithExp(exp int64) ServiceClientOpt { 228 + return func(s *ServiceClientOpts) { 229 + s.exp = exp 230 + } 231 + } 232 + 233 + func WithLxm(lxm string) ServiceClientOpt { 234 + return func(s *ServiceClientOpts) { 235 + s.lxm = lxm 236 + } 237 + } 238 + 239 + func WithDev(dev bool) ServiceClientOpt { 240 + return func(s *ServiceClientOpts) { 241 + s.dev = dev 242 + } 243 + } 244 + 245 + func (s *ServiceClientOpts) Audience() string { 246 + return fmt.Sprintf("did:web:%s", s.service) 247 + } 248 + 249 + func (s *ServiceClientOpts) Host() string { 250 + scheme := "https://" 251 + if s.dev { 252 + scheme = "http://" 253 + } 254 + 255 + return scheme + s.service 256 + } 257 + 258 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 259 + opts := ServiceClientOpts{} 260 + for _, o := range os { 261 + o(&opts) 262 + } 263 + 264 + authorizedClient, err := o.AuthorizedClient(r) 265 + if err != nil { 266 + return nil, err 267 + } 268 + 269 + resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 270 + if err != nil { 271 + return nil, err 272 + } 273 + 274 + return &indigo_xrpc.Client{ 275 + Auth: &indigo_xrpc.AuthInfo{ 276 + AccessJwt: resp.Token, 277 + }, 278 + Host: opts.Host(), 279 + }, nil 207 280 } 208 281 209 282 type ClientMetadata struct {
+1 -1
appview/pages/funcmap.go
··· 191 191 if v.Len() == 0 { 192 192 return nil 193 193 } 194 - return v.Slice(0, min(n, v.Len()-1)).Interface() 194 + return v.Slice(0, min(n, v.Len())).Interface() 195 195 }, 196 196 197 197 "markdown": func(text string) template.HTML {
+2 -2
appview/pages/markup/camo.go
··· 9 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+80 -9
appview/pages/pages.go
··· 16 16 "strings" 17 17 "sync" 18 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 19 20 "tangled.sh/tangled.sh/core/appview/commitverify" 20 21 "tangled.sh/tangled.sh/core/appview/config" 21 22 "tangled.sh/tangled.sh/core/appview/db" ··· 33 34 "github.com/bluesky-social/indigo/atproto/syntax" 34 35 "github.com/go-git/go-git/v5/plumbing" 35 36 "github.com/go-git/go-git/v5/plumbing/object" 36 - "github.com/microcosm-cc/bluemonday" 37 37 ) 38 38 39 39 //go:embed templates/* static ··· 262 262 return p.executePlain("user/login", w, params) 263 263 } 264 264 265 + type SignupParams struct{} 266 + 267 + func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 + return p.executePlain("user/completeSignup", w, params) 269 + } 270 + 271 + type TermsOfServiceParams struct { 272 + LoggedInUser *oauth.User 273 + } 274 + 275 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 276 + return p.execute("legal/terms", w, params) 277 + } 278 + 279 + type PrivacyPolicyParams struct { 280 + LoggedInUser *oauth.User 281 + } 282 + 283 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 284 + return p.execute("legal/privacy", w, params) 285 + } 286 + 265 287 type TimelineParams struct { 266 288 LoggedInUser *oauth.User 267 289 Timeline []db.TimelineEvent ··· 448 470 return p.executePlain("user/fragments/editPins", w, params) 449 471 } 450 472 451 - type RepoActionsFragmentParams struct { 473 + type RepoStarFragmentParams struct { 452 474 IsStarred bool 453 475 RepoAt syntax.ATURI 454 476 Stats db.RepoStats 455 477 } 456 478 457 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 458 - return p.executePlain("repo/fragments/repoActions", w, params) 479 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 480 + return p.executePlain("repo/fragments/repoStar", w, params) 459 481 } 460 482 461 483 type RepoDescriptionParams struct { ··· 502 524 ext := filepath.Ext(params.ReadmeFileName) 503 525 switch ext { 504 526 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 527 + htmlString = p.rctx.Sanitize(htmlString) 505 528 htmlString = p.rctx.RenderMarkdown(params.Readme) 506 529 params.Raw = false 507 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 530 + params.HTMLReadme = template.HTML(htmlString) 508 531 default: 509 - htmlString = string(params.Readme) 510 532 params.Raw = true 511 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 512 533 } 513 534 } 514 535 ··· 555 576 RepoInfo repoinfo.RepoInfo 556 577 Active string 557 578 BreadCrumbs [][]string 558 - BaseTreeLink string 559 - BaseBlobLink string 579 + TreePath string 560 580 types.RepoTreeResponse 561 581 } 562 582 ··· 626 646 LoggedInUser *oauth.User 627 647 RepoInfo repoinfo.RepoInfo 628 648 Active string 649 + Unsupported bool 650 + IsImage bool 651 + IsVideo bool 652 + ContentSrc string 629 653 BreadCrumbs [][]string 630 654 ShowRendered bool 631 655 RenderToggle bool ··· 693 717 Branches []types.Branch 694 718 Spindles []string 695 719 CurrentSpindle string 720 + Secrets []*tangled.RepoListSecrets_Secret 721 + 696 722 // TODO: use repoinfo.roles 697 723 IsCollaboratorInviteAllowed bool 698 724 } ··· 702 728 return p.executeRepo("repo/settings", w, params) 703 729 } 704 730 731 + type RepoGeneralSettingsParams struct { 732 + LoggedInUser *oauth.User 733 + RepoInfo repoinfo.RepoInfo 734 + Active string 735 + Tabs []map[string]any 736 + Tab string 737 + Branches []types.Branch 738 + } 739 + 740 + func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 741 + params.Active = "settings" 742 + return p.executeRepo("repo/settings/general", w, params) 743 + } 744 + 745 + type RepoAccessSettingsParams struct { 746 + LoggedInUser *oauth.User 747 + RepoInfo repoinfo.RepoInfo 748 + Active string 749 + Tabs []map[string]any 750 + Tab string 751 + Collaborators []Collaborator 752 + } 753 + 754 + func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 755 + params.Active = "settings" 756 + return p.executeRepo("repo/settings/access", w, params) 757 + } 758 + 759 + type RepoPipelineSettingsParams struct { 760 + LoggedInUser *oauth.User 761 + RepoInfo repoinfo.RepoInfo 762 + Active string 763 + Tabs []map[string]any 764 + Tab string 765 + Spindles []string 766 + CurrentSpindle string 767 + Secrets []map[string]any 768 + } 769 + 770 + func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 771 + params.Active = "settings" 772 + return p.executeRepo("repo/settings/pipelines", w, params) 773 + } 774 + 705 775 type RepoIssuesParams struct { 706 776 LoggedInUser *oauth.User 707 777 RepoInfo repoinfo.RepoInfo ··· 813 883 DidHandleMap map[string]string 814 884 FilteringBy db.PullState 815 885 Stacks map[string]db.Stack 886 + Pipelines map[string]db.Pipeline 816 887 } 817 888 818 889 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
+1 -1
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addKnotMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+19 -30
appview/pages/templates/layouts/base.html
··· 14 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 15 {{ block "extrameta" . }}{{ end }} 16 16 </head> 17 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 - <div class="px-1" style="z-index: 20"> 19 - {{ block "topbarLayout" . }} 20 - <div class="grid grid-cols-1 md:grid-cols-12"> 21 - <header class="col-span-1 md:col-start-3 md:col-span-8"> 22 - {{ template "layouts/topbar" . }} 23 - </header> 24 - </div> 25 - {{ end }} 26 - </div> 17 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 + {{ block "topbarLayout" . }} 19 + <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 + {{ template "layouts/topbar" . }} 21 + </header> 22 + {{ end }} 27 23 28 - <div class="px-1 flex flex-col min-h-screen gap-4"> 29 - {{ block "contentLayout" . }} 30 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 24 + {{ block "mainLayout" . }} 25 + <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 + {{ block "contentLayout" . }} 31 27 <div class="col-span-1 md:col-span-2"> 32 28 {{ block "contentLeft" . }} {{ end }} 33 29 </div> ··· 37 33 <div class="col-span-1 md:col-span-2"> 38 34 {{ block "contentRight" . }} {{ end }} 39 35 </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ block "contentAfterLayout" . }} 44 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 36 + {{ end }} 37 + 38 + {{ block "contentAfterLayout" . }} 45 39 <div class="col-span-1 md:col-span-2"> 46 40 {{ block "contentAfterLeft" . }} {{ end }} 47 41 </div> ··· 51 45 <div class="col-span-1 md:col-span-2"> 52 46 {{ block "contentAfterRight" . }} {{ end }} 53 47 </div> 54 - </div> 55 - {{ end }} 56 - </div> 57 - 58 - <div class="px-1 mt-16"> 59 - {{ block "footerLayout" . }} 60 - <div class="grid grid-cols-1 md:grid-cols-12"> 61 - <footer class="col-span-1 md:col-start-3 md:col-span-8"> 62 - {{ template "layouts/footer" . }} 63 - </footer> 48 + {{ end }} 64 49 </div> 65 - {{ end }} 66 - </div> 50 + {{ end }} 67 51 52 + {{ block "footerLayout" . }} 53 + <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 + {{ template "layouts/footer" . }} 55 + </footer> 56 + {{ end }} 68 57 </body> 69 58 </html> 70 59 {{ end }}
+41 -3
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 drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 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> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 12 + <div class="flex flex-col gap-1"> 13 + <div class="font-medium text-xs uppercase tracking-wide mb-1">legal</div> 14 + <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline flex gap-1 items-center">{{ i "file-text" "w-4 h-4 flex-shrink-0" }} terms of service</a> 15 + <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "shield" "w-4 h-4 flex-shrink-0" }} privacy policy</a> 16 + </div> 17 + 18 + <div class="flex flex-col gap-1"> 19 + <div class="font-medium text-xs uppercase tracking-wide mb-1">resources</div> 20 + <a href="https://blog.tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "book-open" "w-4 h-4 flex-shrink-0" }} blog</a> 21 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "book" "w-4 h-4 flex-shrink-0" }} docs</a> 22 + <a href="https://tangled.sh/@tangled.sh/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "code" "w-4 h-4 flex-shrink-0" }} source</a> 23 + </div> 24 + 25 + <div class="flex flex-col gap-1"> 26 + <div class="font-medium text-xs uppercase tracking-wide mb-1">social</div> 27 + <a href="https://chat.tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "message-circle" "w-4 h-4 flex-shrink-0" }} discord</a> 28 + <a href="https://web.libera.chat/#tangled" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "hash" "w-4 h-4 flex-shrink-0" }} irc</a> 29 + <a href="https://bsky.app/profile/tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" "w-4 h-4 flex-shrink-0 hover:text-gray-900 dark:hover:text-gray-200dark:text-white" }} bluesky</a> 30 + </div> 31 + 32 + <div class="flex flex-col gap-1"> 33 + <div class="font-medium text-xs uppercase tracking-wide mb-1">contact</div> 34 + <a href="mailto:team@tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 35 + <a href="mailto:security@tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 36 + </div> 37 + </div> 38 + 39 + <div class="text-center lg:text-right flex-shrink-0"> 40 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 41 + </div> 42 + </div> 5 43 </div> 6 44 </div> 7 45 {{ end }}
+23 -1
appview/pages/templates/layouts/repobase.html
··· 19 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 20 </div> 21 21 22 - {{ template "repo/fragments/repoActions" .RepoInfo }} 22 + <div class="flex items-center gap-2 z-auto"> 23 + {{ template "repo/fragments/repoStar" .RepoInfo }} 24 + {{ if .RepoInfo.DisableFork }} 25 + <button 26 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 + disabled 28 + title="Empty repositories cannot be forked" 29 + > 30 + {{ i "git-fork" "w-4 h-4" }} 31 + fork 32 + </button> 33 + {{ else }} 34 + <a 35 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 + hx-boost="true" 37 + href="/{{ .RepoInfo.FullName }}/fork" 38 + > 39 + {{ i "git-fork" "w-4 h-4" }} 40 + fork 41 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 + </a> 43 + {{ end }} 44 + </div> 23 45 </div> 24 46 {{ template "repo/fragments/repoDescription" . }} 25 47 </section>
+2 -13
appview/pages/templates/layouts/topbar.html
··· 1 1 {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 10 <div id="right-items" class="flex items-center gap-4"> 23 11 {{ with .LoggedInUser }} 24 12 <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> ··· 45 33 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 46 34 > 47 35 <a href="/{{ $user }}">profile</a> 36 + <a href="/{{ $user }}?tab=repos">repositories</a> 48 37 <a href="/knots">knots</a> 49 38 <a href="/spindles">spindles</a> 50 39 <a href="/settings">settings</a>
+133
appview/pages/templates/legal/privacy.html
··· 1 + {{ define "title" }} privacy policy {{ end }} 2 + {{ define "content" }} 3 + <div class="max-w-4xl mx-auto px-4 py-8"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 + <h1>Privacy Policy</h1> 7 + 8 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 + 10 + <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 + 12 + <h2>1. Information We Collect</h2> 13 + 14 + <h3>Account Information</h3> 15 + <p>When you create an account, we collect:</p> 16 + <ul> 17 + <li>Your chosen username</li> 18 + <li>Email address</li> 19 + <li>Profile information you choose to provide</li> 20 + <li>Authentication data</li> 21 + </ul> 22 + 23 + <h3>Content and Activity</h3> 24 + <p>We store:</p> 25 + <ul> 26 + <li>Code repositories and associated metadata</li> 27 + <li>Issues, pull requests, and comments</li> 28 + <li>Activity logs and usage patterns</li> 29 + <li>Public keys for authentication</li> 30 + </ul> 31 + 32 + <h2>2. Data Location and Hosting</h2> 33 + <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 + <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 + <p class="text-blue-700 dark:text-blue-300"> 36 + <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 + </p> 38 + <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 + <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 + <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 + <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 + </ul> 43 + </div> 44 + 45 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 + <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 + <p class="text-yellow-700 dark:text-yellow-300"> 48 + <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 + </p> 50 + </div> 51 + 52 + <h2>3. Third-Party Data Processors</h2> 53 + <p>We only share your data with the following third-party processors:</p> 54 + 55 + <h3>Resend (Email Services)</h3> 56 + <ul> 57 + <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 + <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 + <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 + </ul> 61 + 62 + <h3>Cloudflare (Image Caching)</h3> 63 + <ul> 64 + <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 + <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 + <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 + </ul> 68 + 69 + <h2>4. How We Use Your Information</h2> 70 + <p>We use your information to:</p> 71 + <ul> 72 + <li>Provide and maintain the Service</li> 73 + <li>Process your transactions and requests</li> 74 + <li>Send you technical notices and support messages</li> 75 + <li>Improve and develop new features</li> 76 + <li>Ensure security and prevent fraud</li> 77 + <li>Comply with legal obligations</li> 78 + </ul> 79 + 80 + <h2>5. Data Sharing and Disclosure</h2> 81 + <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 + <ul> 83 + <li>With the third-party processors listed above</li> 84 + <li>When required by law or legal process</li> 85 + <li>To protect our rights, property, or safety, or that of our users</li> 86 + <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 + </ul> 88 + 89 + <h2>6. Data Security</h2> 90 + <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 + 92 + <h2>7. Data Retention</h2> 93 + <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 + 95 + <h2>8. Your Rights</h2> 96 + <p>Under applicable data protection laws, you have the right to:</p> 97 + <ul> 98 + <li>Access your personal information</li> 99 + <li>Correct inaccurate information</li> 100 + <li>Request deletion of your information</li> 101 + <li>Object to processing of your information</li> 102 + <li>Data portability</li> 103 + <li>Withdraw consent (where applicable)</li> 104 + </ul> 105 + 106 + <h2>9. Cookies and Tracking</h2> 107 + <p>We use cookies and similar technologies to:</p> 108 + <ul> 109 + <li>Maintain your login session</li> 110 + <li>Remember your preferences</li> 111 + <li>Analyze usage patterns to improve the Service</li> 112 + </ul> 113 + <p>You can control cookie settings through your browser preferences.</p> 114 + 115 + <h2>10. Children's Privacy</h2> 116 + <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 + 118 + <h2>11. International Data Transfers</h2> 119 + <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 + 121 + <h2>12. Changes to This Privacy Policy</h2> 122 + <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 + 124 + <h2>13. Contact Information</h2> 125 + <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 + 127 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 + <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 130 + </div> 131 + </div> 132 + </div> 133 + {{ end }}
+71
appview/pages/templates/legal/terms.html
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + <h1>Terms of Service</h1> 8 + 9 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 + 11 + <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 + 13 + <h2>1. Acceptance of Terms</h2> 14 + <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 + 16 + <h2>2. Account Registration</h2> 17 + <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 + 19 + <h2>3. Account Termination</h2> 20 + <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 + <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 + <p class="text-red-700 dark:text-red-300"> 23 + <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 + </p> 25 + <p class="text-red-700 dark:text-red-300 mt-2"> 26 + Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 + </p> 28 + </div> 29 + 30 + <h2>4. Acceptable Use</h2> 31 + <p>You agree not to use the Service to:</p> 32 + <ul> 33 + <li>Violate any applicable laws or regulations</li> 34 + <li>Infringe upon the rights of others</li> 35 + <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 + <li>Engage in spam, phishing, or other deceptive practices</li> 37 + <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 + <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 + </ul> 40 + 41 + <h2>5. Content and Intellectual Property</h2> 42 + <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 + 44 + <h2>6. Privacy</h2> 45 + <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 + 47 + <h2>7. Disclaimers</h2> 48 + <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 + 50 + <h2>8. Limitation of Liability</h2> 51 + <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 + 53 + <h2>9. Indemnification</h2> 54 + <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 + 56 + <h2>10. Governing Law</h2> 57 + <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 + 59 + <h2>11. Changes to Terms</h2> 60 + <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 + 62 + <h2>12. Contact Information</h2> 63 + <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 + 65 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 + <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 68 + </div> 69 + </div> 70 + </div> 71 + {{ end }}
+19 -6
appview/pages/templates/repo/blob.html
··· 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 10 + 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} ··· 44 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 45 {{ if .RenderToggle }} 46 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 49 hx-boost="true" 50 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 51 {{ end }} 52 52 </div> 53 53 </div> 54 54 </div> 55 - {{ if .IsBinary }} 55 + {{ if and .IsBinary .Unsupported }} 56 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 57 + Previews are not supported for this file type. 58 58 </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 59 72 {{ else }} 60 73 <div class="overflow-auto relative"> 61 74 {{ if .ShowRendered }}
+22 -14
appview/pages/templates/repo/commit.html
··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - {{ template "layouts/topbar" . }} 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/topbar" . }} 85 + </header> 84 86 {{ end }} 85 87 86 - {{ define "contentLayout" }} 87 - {{ block "content" . }}{{ end }} 88 - {{ end }} 88 + {{ define "mainLayout" }} 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 + {{ block "contentLayout" . }} 91 + {{ block "content" . }}{{ end }} 92 + {{ end }} 89 93 90 - {{ define "contentAfterLayout" }} 91 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 92 - <div class="col-span-1 md:col-span-2"> 93 - {{ block "contentAfterLeft" . }} {{ end }} 94 + {{ block "contentAfterLayout" . }} 95 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 96 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 97 + {{ block "contentAfterLeft" . }} {{ end }} 98 + </div> 99 + <main class="col-span-1 md:col-span-10"> 100 + {{ block "contentAfter" . }}{{ end }} 101 + </main> 94 102 </div> 95 - <main class="col-span-1 md:col-span-10"> 96 - {{ block "contentAfter" . }}{{ end }} 97 - </main> 103 + {{ end }} 98 104 </div> 99 105 {{ end }} 100 106 101 - {{ define "footerLayout" }} 102 - {{ template "layouts/footer" . }} 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/footer" . }} 110 + </footer> 103 111 {{ end }} 104 112 105 113 {{ define "contentAfter" }} ··· 110 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 111 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 112 120 </div> 113 - <div class="sticky top-0 mt-4"> 121 + <div class="sticky top-0 flex-grow max-h-screen"> 114 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 115 123 </div> 116 124 {{end}}
+22 -14
appview/pages/templates/repo/compare/compare.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "topbarLayout" }} 14 - {{ template "layouts/topbar" . }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 15 17 {{ end }} 16 18 17 - {{ define "contentLayout" }} 18 - {{ block "content" . }}{{ end }} 19 - {{ end }} 19 + {{ define "mainLayout" }} 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 + {{ block "contentLayout" . }} 22 + {{ block "content" . }}{{ end }} 23 + {{ end }} 20 24 21 - {{ define "contentAfterLayout" }} 22 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 23 - <div class="col-span-1 md:col-span-2"> 24 - {{ block "contentAfterLeft" . }} {{ end }} 25 + {{ block "contentAfterLayout" . }} 26 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 + {{ block "contentAfterLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-10"> 31 + {{ block "contentAfter" . }}{{ end }} 32 + </main> 25 33 </div> 26 - <main class="col-span-1 md:col-span-10"> 27 - {{ block "contentAfter" . }}{{ end }} 28 - </main> 34 + {{ end }} 29 35 </div> 30 36 {{ end }} 31 37 32 - {{ define "footerLayout" }} 33 - {{ template "layouts/footer" . }} 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/footer" . }} 41 + </footer> 34 42 {{ end }} 35 43 36 44 {{ define "contentAfter" }} ··· 41 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 42 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 43 51 </div> 44 - <div class="sticky top-0 mt-4"> 52 + <div class="sticky top-0 flex-grow max-h-screen"> 45 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 46 54 </div> 47 55 {{end}}
+15 -3
appview/pages/templates/repo/empty.html
··· 23 23 {{ end }} 24 24 </div> 25 25 </div> 26 + {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 + {{ $knot := .RepoInfo.Knot }} 28 + {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.sh" }} 30 + {{ end }} 31 + <div class="w-full flex place-content-center"> 32 + <div class="py-6 w-fit flex flex-col gap-4"> 33 + <p>This is an empty repository. To get started:</p> 34 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 + <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 + <p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p> 37 + <p><span class="{{$bullet}}">3</span>Push!</p> 38 + </div> 39 + </div> 26 40 {{ else }} 27 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 28 - This is an empty repository. Push some commits here. 29 - </p> 41 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 30 42 {{ end }} 31 43 </main> 32 44 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 1 {{ define "repo/fragments/diffChangedFiles" }} 2 2 {{ $stat := .Stat }} 3 3 {{ $fileTree := fileTree .ChangedFiles }} 4 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 5 <div class="diff-stat"> 6 6 <div class="flex gap-2 items-center"> 7 7 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 1 {{ define "repo/fragments/interdiffFiles" }} 2 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm md:min-h-screen text-sm"> 3 + <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 4 <div class="diff-stat"> 5 5 <div class="flex gap-2 items-center"> 6 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
-48
appview/pages/templates/repo/fragments/repoActions.html
··· 1 - {{ define "repo/fragments/repoActions" }} 2 - <div class="flex items-center gap-2 z-auto"> 3 - <button 4 - id="starBtn" 5 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 - {{ if .IsStarred }} 7 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 8 - {{ else }} 9 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 10 - {{ end }} 11 - 12 - hx-trigger="click" 13 - hx-target="#starBtn" 14 - hx-swap="outerHTML" 15 - hx-disabled-elt="#starBtn" 16 - > 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" }} 26 - </button> 27 - {{ if .DisableFork }} 28 - <button 29 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 30 - disabled 31 - title="Empty repositories cannot be forked" 32 - > 33 - {{ i "git-fork" "w-4 h-4" }} 34 - fork 35 - </button> 36 - {{ else }} 37 - <a 38 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 39 - hx-boost="true" 40 - href="/{{ .FullName }}/fork" 41 - > 42 - {{ i "git-fork" "w-4 h-4" }} 43 - fork 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </a> 46 - {{ end }} 47 - </div> 48 - {{ end }}
+26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 + {{ define "repo/fragments/repoStar" }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + {{ if .IsStarred }} 6 + hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 + {{ else }} 8 + hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 + {{ end }} 10 + 11 + hx-trigger="click" 12 + hx-target="this" 13 + hx-swap="outerHTML" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .Stats.StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+39 -71
appview/pages/templates/repo/index.html
··· 127 127 {{ end }} 128 128 129 129 {{ define "fileTree" }} 130 - <div 131 - id="file-tree" 132 - class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" 133 - > 134 - {{ $containerstyle := "py-1" }} 135 - {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 130 + <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" > 131 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 136 132 137 - {{ range .Files }} 138 - {{ if not .IsFile }} 139 - <div class="{{ $containerstyle }}"> 140 - <div class="flex justify-between items-center"> 141 - <a 142 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 143 - class="{{ $linkstyle }}" 144 - > 145 - <div class="flex items-center gap-2"> 146 - {{ i "folder" "size-4 fill-current" }} 147 - {{ .Name }} 148 - </div> 149 - </a> 133 + {{ range .Files }} 134 + <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 + <div class="col-span-1"> 136 + {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 + {{ $icon := "folder" }} 138 + {{ $iconStyle := "size-4 fill-current" }} 150 139 151 - {{ if .LastCommit }} 152 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 153 - {{ end }} 154 - </div> 155 - </div> 156 - {{ end }} 157 - {{ end }} 158 - 159 - {{ range .Files }} 160 - {{ if .IsFile }} 161 - <div class="{{ $containerstyle }}"> 162 - <div class="flex justify-between items-center"> 163 - <a 164 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 165 - class="{{ $linkstyle }}" 166 - > 167 - <div class="flex items-center gap-2"> 168 - {{ i "file" "size-4" }}{{ .Name }} 169 - </div> 170 - </a> 140 + {{ if .IsFile }} 141 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 142 + {{ $icon = "file" }} 143 + {{ $iconStyle = "size-4" }} 144 + {{ end }} 145 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 + <div class="flex items-center gap-2"> 147 + {{ i $icon $iconStyle }}{{ .Name }} 148 + </div> 149 + </a> 150 + </div> 171 151 172 - {{ if .LastCommit }} 173 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 174 - {{ end }} 175 - </div> 176 - </div> 177 - {{ end }} 178 - {{ end }} 179 - </div> 152 + <div class="text-xs col-span-1 text-right"> 153 + {{ with .LastCommit }} 154 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 + {{ end }} 156 + </div> 157 + </div> 158 + {{ end }} 159 + </div> 180 160 {{ end }} 181 161 182 162 {{ define "rightInfo" }} ··· 190 170 {{ define "commitLog" }} 191 171 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 192 172 <div class="flex justify-between items-center"> 193 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 194 - <div class="flex gap-2 items-center font-bold"> 195 - {{ i "logs" "w-4 h-4" }} commits 196 - </div> 197 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 198 - view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 199 - </span> 173 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 174 + {{ i "logs" "w-4 h-4" }} commits 175 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 200 176 </a> 201 177 </div> 202 178 <div class="flex flex-col gap-6"> ··· 298 274 {{ define "branchList" }} 299 275 {{ if gt (len .BranchesTrunc) 0 }} 300 276 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 301 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 302 - <div class="flex gap-2 items-center font-bold"> 303 - {{ i "git-branch" "w-4 h-4" }} branches 304 - </div> 305 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 306 - view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 307 - </span> 277 + <a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 278 + {{ i "git-branch" "w-4 h-4" }} branches 279 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 308 280 </a> 309 281 <div class="flex flex-col gap-1"> 310 282 {{ range .BranchesTrunc }} ··· 341 313 {{ if gt (len .TagsTrunc) 0 }} 342 314 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 343 315 <div class="flex justify-between items-center"> 344 - <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 345 - <div class="flex gap-2 items-center font-bold"> 346 - {{ i "tags" "w-4 h-4" }} tags 347 - </div> 348 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 349 - view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 350 - </span> 316 + <a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 317 + {{ i "tags" "w-4 h-4" }} tags 318 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 351 319 </a> 352 320 </div> 353 321 <div class="flex flex-col gap-1"> ··· 378 346 {{ end }} 379 347 380 348 {{ define "repoAfter" }} 381 - {{- if .HTMLReadme -}} 349 + {{- if or .HTMLReadme .Readme -}} 382 350 <section 383 351 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 }} 384 352 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 386 354 dark:[&_pre]:border dark:[&_pre]:border-gray-700 387 355 {{ end }}" 388 356 > 389 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll"> 390 - {{- .HTMLReadme -}} 357 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 + {{- .Readme -}} 391 359 </pre> 392 360 {{- else -}} 393 361 {{ .HTMLReadme }}
+2 -4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 7 ··· 9 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 10 {{ if $isIssueAuthor }} 11 11 <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 12 author 14 - </span> 15 13 {{ end }} 16 14 17 15 <span class="before:content-['ยท']"></span> 18 16 <a 19 17 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 21 19 id="{{ .CommentId }}"> 22 20 {{ template "repo/fragments/time" .Created }} 23 21 </a>
+7 -8
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 5 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 6 {{ template "user/fragments/picHandleLink" $owner }} 7 7 8 + <!-- show user "hats" --> 9 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 + {{ if $isIssueAuthor }} 11 + <span class="before:content-['ยท']"></span> 12 + author 13 + {{ end }} 14 + 8 15 <span class="before:content-['ยท']"></span> 9 16 <a 10 17 href="#{{ .CommentId }}" ··· 18 25 {{ template "repo/fragments/time" .Created }} 19 26 {{ end }} 20 27 </a> 21 - 22 - <!-- show user "hats" --> 23 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 - {{ if $isIssueAuthor }} 25 - <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"> 26 - author 27 - </span> 28 - {{ end }} 29 28 30 29 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 30 {{ if and $isCommentOwner (not .Deleted) }}
+72 -75
appview/pages/templates/repo/log.html
··· 14 14 </h2> 15 15 16 16 <!-- desktop view (hidden on small screens) --> 17 - <table class="w-full border-collapse hidden md:table"> 18 - <thead> 19 - <tr> 20 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th> 21 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th> 22 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th> 23 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th> 24 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th> 25 - </tr> 26 - </thead> 27 - <tbody> 28 - {{ range $index, $commit := .Commits }} 29 - {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 30 - <tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 31 - <td class=" py-3 align-top"> 32 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 33 - {{ if $didOrHandle }} 34 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 35 - {{ else }} 36 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 37 - {{ end }} 38 - </td> 39 - <td class="py-3 align-top font-mono flex items-center"> 40 - {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 41 - {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 42 - {{ if $verified }} 43 - {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 44 - {{ end }} 45 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 46 - {{ slice $commit.Hash.String 0 8 }} 47 - {{ if $verified }} 48 - {{ i "shield-check" "w-4 h-4" }} 49 - {{ end }} 50 - </a> 51 - <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 52 - <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 53 - title="Copy SHA" 54 - onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 55 - {{ i "copy" "w-4 h-4" }} 56 - </button> 57 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 58 - {{ i "folder-code" "w-4 h-4" }} 59 - </a> 60 - </div> 17 + <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 + {{ $grid := "grid grid-cols-14 gap-4" }} 19 + <div class="{{ $grid }}"> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 21 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 + </div> 26 + {{ range $index, $commit := .Commits }} 27 + {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 + <div class="{{ $grid }} py-3"> 29 + <div class="align-top truncate col-span-2"> 30 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 + {{ if $didOrHandle }} 32 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 + {{ else }} 34 + <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 + {{ end }} 36 + </div> 37 + <div class="align-top font-mono flex items-start col-span-3"> 38 + {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 39 + {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 40 + {{ if $verified }} 41 + {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 42 + {{ end }} 43 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 44 + {{ slice $commit.Hash.String 0 8 }} 45 + {{ if $verified }} 46 + {{ i "shield-check" "w-4 h-4" }} 47 + {{ end }} 48 + </a> 49 + <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 50 + <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 51 + title="Copy SHA" 52 + onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 53 + {{ i "copy" "w-4 h-4" }} 54 + </button> 55 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 56 + {{ i "folder-code" "w-4 h-4" }} 57 + </a> 58 + </div> 61 59 62 - </td> 63 - <td class=" py-3 align-top"> 64 - <div class="flex items-center justify-start gap-2"> 65 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 66 - {{ if gt (len $messageParts) 1 }} 67 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 68 - {{ end }} 60 + </div> 61 + <div class="align-top col-span-6"> 62 + <div> 63 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 64 + {{ if gt (len $messageParts) 1 }} 65 + <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 + {{ end }} 69 67 70 - {{ if index $.TagMap $commit.Hash.String }} 71 - {{ range $tag := index $.TagMap $commit.Hash.String }} 72 - <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 73 - {{ $tag }} 74 - </span> 75 - {{ end }} 76 - {{ end }} 77 - </div> 68 + {{ if index $.TagMap $commit.Hash.String }} 69 + {{ range $tag := index $.TagMap $commit.Hash.String }} 70 + <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 71 + {{ $tag }} 72 + </span> 73 + {{ end }} 74 + {{ end }} 75 + </div> 78 76 79 - {{ if gt (len $messageParts) 1 }} 80 - <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 81 - {{ end }} 82 - </td> 83 - <td class="py-3 align-top"> 84 - <!-- ci status --> 85 - {{ $pipeline := index $.Pipelines .Hash.String }} 86 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 87 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 88 - {{ end }} 89 - </td> 90 - <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $commit.Committer.When }}</td> 91 - </tr> 92 - {{ end }} 93 - </tbody> 94 - </table> 77 + {{ if gt (len $messageParts) 1 }} 78 + <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 + {{ end }} 80 + </div> 81 + <div class="align-top col-span-1"> 82 + <!-- ci status --> 83 + {{ $pipeline := index $.Pipelines .Hash.String }} 84 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 + {{ end }} 87 + </div> 88 + <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 + </div> 90 + {{ end }} 91 + </div> 95 92 96 93 <!-- mobile view (visible only on small screens) --> 97 94 <div class="md:hidden">
+5 -7
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 13 13 </span> 14 14 </div> 15 15 16 - <div class="flex-shrink-0 flex items-center"> 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 17 {{ $latestRound := .LastRoundNumber }} 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 19 {{ $commentCount := len $lastSubmission.Comments }} 20 20 {{ if and $pipeline $pipeline.Id }} 21 - <div class="inline-flex items-center gap-2"> 22 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 23 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 24 - </div> 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 25 23 {{ end }} 26 24 <span> 27 - <div class="inline-flex items-center gap-2"> 25 + <div class="inline-flex items-center gap-1"> 28 26 {{ i "message-square" "w-3 h-3 md:hidden" }} 29 27 {{ $commentCount }} 30 28 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 31 29 </div> 32 30 </span> 33 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 34 32 <span> 35 33 <span class="hidden md:inline">round</span> 36 34 <span class="font-mono">#{{ $latestRound }}</span>
+22 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 29 29 {{ end }} 30 30 31 31 {{ define "topbarLayout" }} 32 - {{ template "layouts/topbar" . }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/topbar" . }} 34 + </header> 33 35 {{ end }} 34 36 35 - {{ define "contentLayout" }} 36 - {{ block "content" . }}{{ end }} 37 - {{ end }} 37 + {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 + {{ block "contentLayout" . }} 40 + {{ block "content" . }}{{ end }} 41 + {{ end }} 38 42 39 - {{ define "contentAfterLayout" }} 40 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 41 - <div class="col-span-1 md:col-span-2"> 42 - {{ block "contentAfterLeft" . }} {{ end }} 43 + {{ block "contentAfterLayout" . }} 44 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-10"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 43 51 </div> 44 - <main class="col-span-1 md:col-span-10"> 45 - {{ block "contentAfter" . }}{{ end }} 46 - </main> 52 + {{ end }} 47 53 </div> 48 54 {{ end }} 49 55 50 - {{ define "footerLayout" }} 51 - {{ template "layouts/footer" . }} 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/footer" . }} 59 + </footer> 52 60 {{ end }} 53 61 54 62 ··· 60 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 61 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 62 70 </div> 63 - <div class="sticky top-0 mt-4"> 71 + <div class="sticky top-0 flex-grow max-h-screen"> 64 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 65 73 </div> 66 74 {{end}}
+22 -14
appview/pages/templates/repo/pulls/patch.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "topbarLayout" }} 38 - {{ template "layouts/topbar" . }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/topbar" . }} 40 + </header> 39 41 {{ end }} 40 42 41 - {{ define "contentLayout" }} 42 - {{ block "content" . }}{{ end }} 43 - {{ end }} 43 + {{ define "mainLayout" }} 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + {{ block "content" . }}{{ end }} 47 + {{ end }} 44 48 45 - {{ define "contentAfterLayout" }} 46 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 47 - <div class="col-span-1 md:col-span-2"> 48 - {{ block "contentAfterLeft" . }} {{ end }} 49 + {{ block "contentAfterLayout" . }} 50 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 51 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 52 + {{ block "contentAfterLeft" . }} {{ end }} 53 + </div> 54 + <main class="col-span-1 md:col-span-10"> 55 + {{ block "contentAfter" . }}{{ end }} 56 + </main> 49 57 </div> 50 - <main class="col-span-1 md:col-span-10"> 51 - {{ block "contentAfter" . }}{{ end }} 52 - </main> 58 + {{ end }} 53 59 </div> 54 60 {{ end }} 55 61 56 - {{ define "footerLayout" }} 57 - {{ template "layouts/footer" . }} 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/footer" . }} 65 + </footer> 58 66 {{ end }} 59 67 60 68 {{ define "contentAfter" }} ··· 65 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 66 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 67 75 </div> 68 - <div class="sticky top-0 mt-4"> 76 + <div class="sticky top-0 flex-grow max-h-screen"> 69 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 70 78 </div> 71 79 {{end}}
+43 -52
appview/pages/templates/repo/pulls/pulls.html
··· 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 58 {{ $owner := index $.DidHandleMap .OwnerDid }} 59 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 60 {{ $icon := "ban" }} ··· 83 83 {{ template "repo/fragments/time" .Created }} 84 84 </span> 85 85 86 + 87 + {{ $latestRound := .LastRoundNumber }} 88 + {{ $lastSubmission := index .Submissions $latestRound }} 89 + 86 90 <span class="before:content-['ยท']"> 87 - targeting 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 - {{ .TargetBranch }} 90 - </span> 91 + {{ $commentCount := len $lastSubmission.Comments }} 92 + {{ $s := "s" }} 93 + {{ if eq $commentCount 1 }} 94 + {{ $s = "" }} 95 + {{ end }} 96 + 97 + {{ len $lastSubmission.Comments}} comment{{$s}} 91 98 </span> 92 - {{ if not .IsPatchBased }} 93 - from 94 - <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"> 95 - {{ if .IsForkBased }} 96 - {{ if .PullSource.Repo }} 97 - <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 98 - {{- else -}} 99 - <span class="italic">[deleted fork]</span> 100 - {{- end -}} 101 - {{- end -}} 102 - {{- .PullSource.Branch -}} 99 + 100 + <span class="before:content-['ยท']"> 101 + round 102 + <span class="font-mono"> 103 + #{{ .LastRoundNumber }} 104 + </span> 103 105 </span> 106 + 107 + {{ $pipeline := index $.Pipelines .LatestSha }} 108 + {{ if and $pipeline $pipeline.Id }} 109 + <span class="before:content-['ยท']"></span> 110 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 104 111 {{ end }} 105 - <span class="before:content-['ยท']"> 106 - {{ $latestRound := .LastRoundNumber }} 107 - {{ $lastSubmission := index .Submissions $latestRound }} 108 - round 109 - <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"> 110 - #{{ .LastRoundNumber }} 111 - </span> 112 - {{ $commentCount := len $lastSubmission.Comments }} 113 - {{ $s := "s" }} 114 - {{ if eq $commentCount 1 }} 115 - {{ $s = "" }} 116 - {{ end }} 117 - 118 - {{ if eq $commentCount 0 }} 119 - awaiting comments 120 - {{ else }} 121 - recieved {{ len $lastSubmission.Comments}} comment{{$s}} 122 - {{ end }} 123 - </span> 124 - </p> 112 + </div> 125 113 </div> 126 114 {{ if .StackId }} 127 115 {{ $otherPulls := index $.Stacks .StackId }} 128 - <details class="bg-white dark:bg-gray-800 group"> 129 - <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 130 - {{ $s := "s" }} 131 - {{ if eq (len $otherPulls) 1 }} 132 - {{ $s = "" }} 133 - {{ end }} 134 - <div class="group-open:hidden flex items-center gap-2"> 135 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 136 - </div> 137 - <div class="hidden group-open:flex items-center gap-2"> 138 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 139 - </div> 140 - </summary> 141 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 142 - </details> 116 + {{ if gt (len $otherPulls) 0 }} 117 + <details class="bg-white dark:bg-gray-800 group"> 118 + <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 119 + {{ $s := "s" }} 120 + {{ if eq (len $otherPulls) 1 }} 121 + {{ $s = "" }} 122 + {{ end }} 123 + <div class="group-open:hidden flex items-center gap-2"> 124 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 125 + </div> 126 + <div class="hidden group-open:flex items-center gap-2"> 127 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 128 + </div> 129 + </summary> 130 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 131 + </details> 132 + {{ end }} 143 133 {{ end }} 144 134 </div> 145 135 {{ end }} ··· 151 141 {{ $root := index . 1 }} 152 142 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 153 143 {{ range $pull := $list }} 144 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 154 145 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 155 146 <div class="flex gap-2 items-center px-6"> 156 147 <div class="flex-grow min-w-0 w-full py-2"> 157 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 148 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 158 149 </div> 159 150 </div> 160 151 </a>
+110
appview/pages/templates/repo/settings/access.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "collaboratorSettings" . }} 10 + </div> 11 + </section> 12 + {{ end }} 13 + 14 + {{ define "collaboratorSettings" }} 15 + <div class="grid grid-cols-1 gap-4 items-center"> 16 + <div class="col-span-1"> 17 + <h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2> 18 + <p class="text-gray-500 dark:text-gray-400"> 19 + Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows. 20 + </p> 21 + </div> 22 + {{ template "collaboratorsGrid" . }} 23 + </div> 24 + {{ end }} 25 + 26 + {{ define "collaboratorsGrid" }} 27 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 28 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 29 + {{ template "addCollaboratorButton" . }} 30 + {{ end }} 31 + {{ range .Collaborators }} 32 + <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 + <div class="flex items-center gap-3"> 34 + <img 35 + src="{{ fullAvatar .Handle }}" 36 + alt="{{ .Handle }}" 37 + class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 + 39 + <div class="flex-1 min-w-0"> 40 + <a href="/{{ .Handle }}" class="block truncate"> 41 + {{ didOrHandle .Did .Handle }} 42 + </a> 43 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 + </div> 45 + </div> 46 + </div> 47 + {{ end }} 48 + </div> 49 + {{ end }} 50 + 51 + {{ define "addCollaboratorButton" }} 52 + <button 53 + class="btn block rounded p-4" 54 + popovertarget="add-collaborator-modal" 55 + popovertargetaction="toggle"> 56 + <div class="flex items-center gap-3"> 57 + <div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 58 + {{ i "user-plus" "size-4" }} 59 + </div> 60 + 61 + <div class="text-left flex-1 min-w-0 block truncate"> 62 + Add collaborator 63 + </div> 64 + </div> 65 + </button> 66 + <div 67 + id="add-collaborator-modal" 68 + popover 69 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 70 + {{ template "addCollaboratorModal" . }} 71 + </div> 72 + {{ end }} 73 + 74 + {{ define "addCollaboratorModal" }} 75 + <form 76 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 77 + hx-indicator="#spinner" 78 + hx-swap="none" 79 + class="flex flex-col gap-2" 80 + > 81 + <label for="add-collaborator" class="uppercase p-0"> 82 + ADD COLLABORATOR 83 + </label> 84 + <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 + <input 86 + type="text" 87 + id="add-collaborator" 88 + name="collaborator" 89 + required 90 + placeholder="@foo.bsky.social" 91 + /> 92 + <div class="flex gap-2 pt-2"> 93 + <button 94 + type="button" 95 + popovertarget="add-collaborator-modal" 96 + popovertargetaction="hide" 97 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 98 + > 99 + {{ i "x" "size-4" }} cancel 100 + </button> 101 + <button type="submit" class="btn w-1/2 flex items-center"> 102 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 103 + <span id="spinner" class="group"> 104 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </span> 106 + </button> 107 + </div> 108 + <div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div> 109 + </form> 110 + {{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
··· 1 + {{ define "repo/settings/fragments/secretListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $secret := index . 1 }} 4 + <div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 6 + <span class="font-mono"> 7 + {{ $secret.Key }} 8 + </span> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 10 + <span>added by</span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 12 + <span class="before:content-['ยท'] before:select-none"></span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 14 + </div> 15 + </div> 16 + <button 17 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 18 + title="Delete secret" 19 + hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 20 + hx-swap="none" 21 + hx-vals='{"key": "{{ $secret.Key }}"}' 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 24 + {{ i "trash-2" "w-5 h-5" }} 25 + <span class="hidden md:inline">delete</span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + </div> 29 + {{ end }}
+16
appview/pages/templates/repo/settings/fragments/sidebar.html
··· 1 + {{ define "repo/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+68
appview/pages/templates/repo/settings/general.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "branchSettings" . }} 10 + {{ template "deleteRepo" . }} 11 + </div> 12 + </section> 13 + {{ end }} 14 + 15 + {{ define "branchSettings" }} 16 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 17 + <div class="col-span-1 md:col-span-2"> 18 + <h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2> 19 + <p class="text-gray-500 dark:text-gray-400"> 20 + The default branch is considered the โ€œbaseโ€ branch in your repository, 21 + against which all pull requests and code commits are automatically made, 22 + unless you specify a different branch. 23 + </p> 24 + </div> 25 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 + <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 27 + <option value="" disabled selected > 28 + Choose a default branch 29 + </option> 30 + {{ range .Branches }} 31 + <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 32 + {{ .Name }} 33 + </option> 34 + {{ end }} 35 + </select> 36 + <button class="btn flex gap-2 items-center" type="submit"> 37 + {{ i "check" "size-4" }} 38 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 + </button> 40 + </form> 41 + </div> 42 + {{ end }} 43 + 44 + {{ define "deleteRepo" }} 45 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 46 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 47 + <div class="col-span-1 md:col-span-2"> 48 + <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2> 49 + <p class="text-red-500 dark:text-red-400 "> 50 + Deleting a repository is irreversible and permanent. Be certain before deleting a repository. 51 + </p> 52 + </div> 53 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 54 + <button 55 + class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 + type="button" 57 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 + hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 + {{ i "trash-2" "size-4" }} 60 + delete 61 + <span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline"> 62 + {{ i "loader-circle" "w-4 h-4" }} 63 + </span> 64 + </button> 65 + </div> 66 + </div> 67 + {{ end }} 68 + {{ end }}
+140
appview/pages/templates/repo/settings/pipelines.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "spindleSettings" . }} 10 + {{ if $.CurrentSpindle }} 11 + {{ template "secretSettings" . }} 12 + {{ end }} 13 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 + </div> 15 + </section> 16 + {{ end }} 17 + 18 + {{ define "spindleSettings" }} 19 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 20 + <div class="col-span-1 md:col-span-2"> 21 + <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 + <p class="text-gray-500 dark:text-gray-400"> 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 26 + click to learn more. 27 + </a> 28 + </p> 29 + </div> 30 + {{ if not $.RepoInfo.Roles.IsOwner }} 31 + <div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 32 + {{ or $.CurrentSpindle "No spindle configured" }} 33 + </div> 34 + {{ else }} 35 + <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 + <select 37 + id="spindle" 38 + name="spindle" 39 + required 40 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 + <option value="" disabled> 42 + Choose a spindle 43 + </option> 44 + {{ range $.Spindles }} 45 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 46 + {{ . }} 47 + </option> 48 + {{ end }} 49 + </select> 50 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 51 + {{ i "check" "size-4" }} 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </button> 54 + </form> 55 + {{ end }} 56 + </div> 57 + {{ end }} 58 + 59 + {{ define "secretSettings" }} 60 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 61 + <div class="col-span-1 md:col-span-2"> 62 + <h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2> 63 + <p class="text-gray-500 dark:text-gray-400"> 64 + Secrets are accessible in workflow runs via environment variables. Anyone 65 + with collaborator access to this repository can add and use secrets in 66 + workflow runs. 67 + </p> 68 + </div> 69 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 70 + {{ template "addSecretButton" . }} 71 + </div> 72 + </div> 73 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 74 + {{ range .Secrets }} 75 + {{ template "repo/settings/fragments/secretListing" (list $ .) }} 76 + {{ else }} 77 + <div class="flex items-center justify-center p-2 text-gray-500"> 78 + no secrets added yet 79 + </div> 80 + {{ end }} 81 + </div> 82 + {{ end }} 83 + 84 + {{ define "addSecretButton" }} 85 + <button 86 + class="btn flex items-center gap-2" 87 + popovertarget="add-secret-modal" 88 + popovertargetaction="toggle"> 89 + {{ i "plus" "size-4" }} 90 + add secret 91 + </button> 92 + <div 93 + id="add-secret-modal" 94 + popover 95 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 96 + {{ template "addSecretModal" . }} 97 + </div> 98 + {{ end}} 99 + 100 + {{ define "addSecretModal" }} 101 + <form 102 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 103 + hx-indicator="#spinner" 104 + hx-swap="none" 105 + class="flex flex-col gap-2" 106 + > 107 + <p class="uppercase p-0">ADD SECRET</p> 108 + <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 109 + <input 110 + type="text" 111 + id="secret-key" 112 + name="key" 113 + required 114 + placeholder="SECRET_NAME" 115 + /> 116 + <textarea 117 + type="text" 118 + id="secret-value" 119 + name="value" 120 + required 121 + placeholder="secret value"></textarea> 122 + <div class="flex gap-2 pt-2"> 123 + <button 124 + type="button" 125 + popovertarget="add-secret-modal" 126 + popovertargetaction="hide" 127 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 128 + > 129 + {{ i "x" "size-4" }} cancel 130 + </button> 131 + <button type="submit" class="btn w-1/2 flex items-center"> 132 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 133 + <span id="spinner" class="group"> 134 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 135 + </span> 136 + </button> 137 + </div> 138 + <div id="add-secret-error" class="text-red-500 dark:text-red-400"></div> 139 + </form> 140 + {{ end }}
+150 -120
appview/pages/templates/repo/settings.html
··· 1 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 2 3 {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 4 + {{ template "collaboratorSettings" . }} 5 + {{ template "branchSettings" . }} 6 + {{ template "dangerZone" . }} 7 + {{ template "spindleSelector" . }} 8 + {{ template "spindleSecrets" . }} 9 + {{ end }} 6 10 7 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 8 - {{ range .Collaborators }} 9 - <div id="collaborator" class="mb-2"> 10 - <a 11 - href="/{{ didOrHandle .Did .Handle }}" 12 - class="no-underline hover:underline text-black dark:text-white" 13 - > 14 - {{ didOrHandle .Did .Handle }} 15 - </a> 16 - <div> 17 - <span class="text-sm text-gray-500 dark:text-gray-400"> 18 - {{ .Role }} 19 - </span> 20 - </div> 21 - </div> 22 - {{ end }} 23 - </div> 11 + {{ define "collaboratorSettings" }} 12 + <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 + Collaborators 14 + </header> 24 15 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 16 + <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 + {{ range .Collaborators }} 18 + <div id="collaborator" class="mb-2"> 19 + <a 20 + href="/{{ didOrHandle .Did .Handle }}" 21 + class="no-underline hover:underline text-black dark:text-white" 29 22 > 30 - <label for="collaborator" class="dark:text-white"> 31 - add collaborator 32 - </label> 33 - <input 34 - type="text" 35 - id="collaborator" 36 - name="collaborator" 37 - required 38 - class="dark:bg-gray-700 dark:text-white" 39 - placeholder="enter did or handle" 40 - > 41 - <button 42 - class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" 43 - type="text" 44 - > 45 - <span>add</span> 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - </form> 23 + {{ didOrHandle .Did .Handle }} 24 + </a> 25 + <div> 26 + <span class="text-sm text-gray-500 dark:text-gray-400"> 27 + {{ .Role }} 28 + </span> 29 + </div> 30 + </div> 49 31 {{ end }} 32 + </div> 50 33 34 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 51 35 <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 36 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 + class="group" 54 38 > 55 - <label for="branch">default branch</label> 56 - <div class="flex gap-2 items-center"> 57 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 58 - <option 59 - value="" 60 - disabled 61 - selected 62 - > 63 - Choose a default branch 64 - </option> 65 - {{ range .Branches }} 66 - <option 67 - value="{{ .Name }}" 68 - class="py-1" 69 - {{ if .IsDefault }} 70 - selected 71 - {{ end }} 72 - > 73 - {{ .Name }} 74 - </option> 75 - {{ end }} 76 - </select> 77 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 78 - <span>save</span> 79 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 80 - </button> 81 - </div> 82 - </form> 83 - 84 - {{ if .RepoInfo.Roles.IsOwner }} 85 - <form 86 - hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" 87 - class="mt-6 group" 88 - > 89 - <label for="spindle">spindle</label> 90 - <div class="flex gap-2 items-center"> 91 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 92 - <option 93 - value="" 94 - selected 95 - > 96 - None 97 - </option> 98 - {{ range .Spindles }} 99 - <option 100 - value="{{ . }}" 101 - class="py-1" 102 - {{ if eq . $.CurrentSpindle }} 103 - selected 104 - {{ end }} 105 - > 106 - {{ . }} 107 - </option> 108 - {{ end }} 109 - </select> 110 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 111 - <span>save</span> 112 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 - </button> 114 - </div> 39 + <label for="collaborator" class="dark:text-white"> 40 + add collaborator 41 + </label> 42 + <input 43 + type="text" 44 + id="collaborator" 45 + name="collaborator" 46 + required 47 + class="dark:bg-gray-700 dark:text-white" 48 + placeholder="enter did or handle"> 49 + <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 + <span>add</span> 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </button> 115 53 </form> 116 - {{ end }} 54 + {{ end }} 55 + {{ end }} 117 56 118 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 57 + {{ define "dangerZone" }} 58 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 119 59 <form 120 60 hx-confirm="Are you sure you want to delete this repository?" 121 61 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 122 62 class="mt-6" 123 - hx-indicator="#delete-repo-spinner" 124 - > 125 - <label for="branch">delete repository</label> 126 - <button class="btn my-2 flex items-center" type="text"> 127 - <span>delete</span> 128 - <span id="delete-repo-spinner" class="group"> 129 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 130 - </span> 131 - </button> 132 - <span> 133 - Deleting a repository is irreversible and permanent. 134 - </span> 63 + hx-indicator="#delete-repo-spinner"> 64 + <label for="branch">delete repository</label> 65 + <button class="btn my-2 flex items-center" type="text"> 66 + <span>delete</span> 67 + <span id="delete-repo-spinner" class="group"> 68 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 + </span> 70 + </button> 71 + <span> 72 + Deleting a repository is irreversible and permanent. 73 + </span> 135 74 </form> 136 - {{ end }} 75 + {{ end }} 76 + {{ end }} 77 + 78 + {{ define "branchSettings" }} 79 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 + <label for="branch">default branch</label> 81 + <div class="flex gap-2 items-center"> 82 + <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 + <option value="" disabled selected > 84 + Choose a default branch 85 + </option> 86 + {{ range .Branches }} 87 + <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 + {{ .Name }} 89 + </option> 90 + {{ end }} 91 + </select> 92 + <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 + <span>save</span> 94 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 + </button> 96 + </div> 97 + </form> 98 + {{ end }} 99 + 100 + {{ define "spindleSelector" }} 101 + {{ if .RepoInfo.Roles.IsOwner }} 102 + <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 + <label for="spindle">spindle</label> 104 + <div class="flex gap-2 items-center"> 105 + <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 + <option value="" selected > 107 + None 108 + </option> 109 + {{ range .Spindles }} 110 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 + {{ . }} 112 + </option> 113 + {{ end }} 114 + </select> 115 + <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 + <span>save</span> 117 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 + </button> 119 + </div> 120 + </form> 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "spindleSecrets" }} 125 + {{ if $.CurrentSpindle }} 126 + <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 + Secrets 128 + </header> 129 + 130 + <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 + {{ range $idx, $secret := .Secrets }} 132 + {{ with $secret }} 133 + <div id="secret-{{$idx}}" class="mb-2"> 134 + {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 + </div> 136 + {{ end }} 137 + {{ end }} 138 + </div> 139 + <form 140 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 + class="mt-6" 142 + hx-indicator="#add-secret-spinner"> 143 + <label for="key">secret key</label> 144 + <input 145 + type="text" 146 + id="key" 147 + name="key" 148 + required 149 + class="dark:bg-gray-700 dark:text-white" 150 + placeholder="SECRET_KEY" /> 151 + <label for="value">secret value</label> 152 + <input 153 + type="text" 154 + id="value" 155 + name="value" 156 + required 157 + class="dark:bg-gray-700 dark:text-white" 158 + placeholder="SECRET VALUE" /> 137 159 160 + <button class="btn my-2 flex items-center" type="text"> 161 + <span>add</span> 162 + <span id="add-secret-spinner" class="group"> 163 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 + </span> 165 + </button> 166 + </form> 167 + {{ end }} 138 168 {{ end }}
+26 -34
appview/pages/templates/repo/tree.html
··· 19 19 {{define "repoContent"}} 20 20 <main> 21 21 <div class="tree"> 22 - {{ $containerstyle := "py-1" }} 23 22 {{ $linkstyle := "no-underline hover:underline" }} 24 23 25 24 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 54 53 </div> 55 54 56 55 {{ range .Files }} 57 - {{ if not .IsFile }} 58 - <div class="{{ $containerstyle }}"> 59 - <div class="flex justify-between items-center"> 60 - <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 61 - <div class="flex items-center gap-2"> 62 - {{ i "folder" "size-4 fill-current" }}{{ .Name }} 63 - </div> 64 - </a> 65 - {{ if .LastCommit}} 66 - <div class="flex items-end gap-2"> 67 - <span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span> 68 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 56 + <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 + <div class="col-span-6 md:col-span-3"> 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 + {{ $icon := "folder" }} 60 + {{ $iconStyle := "size-4 fill-current" }} 61 + 62 + {{ if .IsFile }} 63 + {{ $icon = "file" }} 64 + {{ $iconStyle = "size-4" }} 65 + {{ end }} 66 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 + <div class="flex items-center gap-2"> 68 + {{ i $icon $iconStyle }}{{ .Name }} 69 69 </div> 70 - {{ end }} 70 + </a> 71 71 </div> 72 - </div> 73 - {{ end }} 74 - {{ end }} 75 72 76 - {{ range .Files }} 77 - {{ if .IsFile }} 78 - <div class="{{ $containerstyle }}"> 79 - <div class="flex justify-between items-center"> 80 - <a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 81 - <div class="flex items-center gap-2"> 82 - {{ i "file" "size-4" }}{{ .Name }} 83 - </div> 84 - </a> 85 - {{ if .LastCommit}} 86 - <div class="flex items-end gap-2"> 87 - <span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span> 88 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 89 - </div> 90 - {{ end }} 73 + <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 74 + {{ with .LastCommit }} 75 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 76 + {{ end }} 77 + </div> 78 + 79 + <div class="col-span-6 md:col-span-2 text-right"> 80 + {{ with .LastCommit }} 81 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 82 + {{ end }} 91 83 </div> 92 - </div> 84 + </div> 93 85 {{ end }} 94 - {{ end }} 86 + 95 87 </div> 96 88 </main> 97 89 {{end}}
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+12 -8
appview/pages/templates/timeline.html
··· 143 143 <a href="/{{ $subjectHandle }}"> 144 144 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 145 145 </a> 146 - {{ with $profile.Description }} 147 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 146 + {{ with $profile }} 147 + {{ with .Description }} 148 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 149 + {{ end }} 148 150 {{ end }} 149 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 150 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 151 - <span id="followers">{{ $stat.Followers }} followers</span> 152 - <span class="select-none after:content-['ยท']"></span> 153 - <span id="following">{{ $stat.Following }} following</span> 154 - </div> 151 + {{ with $stat }} 152 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 153 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 154 + <span id="followers">{{ .Followers }} followers</span> 155 + <span class="select-none after:content-['ยท']"></span> 156 + <span id="following">{{ .Following }} following</span> 157 + </div> 158 + {{ end }} 155 159 </div> 156 160 </div> 157 161 {{ end }}
+104
appview/pages/templates/user/completeSignup.html
··· 1 + {{ define "user/completeSignup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta 7 + name="viewport" 8 + content="width=device-width, initial-scale=1.0" 9 + /> 10 + <meta 11 + property="og:title" 12 + content="complete signup ยท tangled" 13 + /> 14 + <meta 15 + property="og:url" 16 + content="https://tangled.sh/complete-signup" 17 + /> 18 + <meta 19 + property="og:description" 20 + content="complete your signup for tangled" 21 + /> 22 + <script src="/static/htmx.min.js"></script> 23 + <link 24 + rel="stylesheet" 25 + href="/static/tw.css?{{ cssContentHash }}" 26 + type="text/css" 27 + /> 28 + <title>complete signup &middot; tangled</title> 29 + </head> 30 + <body class="flex items-center justify-center min-h-screen"> 31 + <main class="max-w-md px-6 -mt-4"> 32 + <h1 33 + class="text-center text-2xl font-semibold italic dark:text-white" 34 + > 35 + tangled 36 + </h1> 37 + <h2 class="text-center text-xl italic dark:text-white"> 38 + tightly-knit social coding. 39 + </h2> 40 + <form 41 + class="mt-4 max-w-sm mx-auto" 42 + hx-post="/signup/complete" 43 + hx-swap="none" 44 + hx-disabled-elt="#complete-signup-button" 45 + > 46 + <div class="flex flex-col"> 47 + <label for="code">verification code</label> 48 + <input 49 + type="text" 50 + id="code" 51 + name="code" 52 + tabindex="1" 53 + required 54 + placeholder="tngl-sh-foo-bar" 55 + /> 56 + <span class="text-sm text-gray-500 mt-1"> 57 + Enter the code sent to your email. 58 + </span> 59 + </div> 60 + 61 + <div class="flex flex-col mt-4"> 62 + <label for="username">desired username</label> 63 + <input 64 + type="text" 65 + id="username" 66 + name="username" 67 + tabindex="2" 68 + required 69 + placeholder="jason" 70 + /> 71 + <span class="text-sm text-gray-500 mt-1"> 72 + Your complete handle will be of the form <code>user.tngl.sh</code>. 73 + </span> 74 + </div> 75 + 76 + <div class="flex flex-col mt-4"> 77 + <label for="password">password</label> 78 + <input 79 + type="password" 80 + id="password" 81 + name="password" 82 + tabindex="3" 83 + required 84 + /> 85 + <span class="text-sm text-gray-500 mt-1"> 86 + Choose a strong password for your account. 87 + </span> 88 + </div> 89 + 90 + <button 91 + class="btn-create w-full my-2 mt-6" 92 + type="submit" 93 + id="complete-signup-button" 94 + tabindex="4" 95 + > 96 + <span>complete signup</span> 97 + </button> 98 + </form> 99 + <p id="signup-error" class="error w-full"></p> 100 + <p id="signup-msg" class="dark:text-white w-full"></p> 101 + </main> 102 + </body> 103 + </html> 104 + {{ end }}
+54 -7
appview/pages/templates/user/login.html
··· 17 17 /> 18 18 <meta 19 19 property="og:description" 20 - content="login to tangled" 20 + content="login to or sign up for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 23 <link ··· 25 25 href="/static/tw.css?{{ cssContentHash }}" 26 26 type="text/css" 27 27 /> 28 - <title>login &middot; tangled</title> 28 + <title>login or sign up &middot; tangled</title> 29 29 </head> 30 30 <body class="flex items-center justify-center min-h-screen"> 31 31 <main class="max-w-md px-6 -mt-4"> ··· 51 51 name="handle" 52 52 tabindex="1" 53 53 required 54 + placeholder="foo.tngl.sh" 54 55 /> 55 56 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 57 + Use your <a href="https://atproto.com">ATProto</a> 58 + handle to log in. If you're unsure, this is likely 59 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 60 </span> 61 61 </div> 62 62 ··· 69 69 <span>login</span> 70 70 </button> 71 71 </form> 72 - <p class="text-sm text-gray-500"> 72 + <hr class="my-4"> 73 + <p class="text-sm text-gray-500 mt-4"> 74 + Alternatively, you may create an account on Tangled below. You will 75 + get a <code>user.tngl.sh</code> handle. 76 + </p> 77 + 78 + <details class="group"> 79 + 80 + <summary 81 + class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2" 82 + > 83 + create an account 84 + 85 + <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div> 86 + <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div> 87 + </summary> 88 + <form 89 + class="mt-4 max-w-sm mx-auto" 90 + hx-post="/signup" 91 + hx-swap="none" 92 + hx-disabled-elt="#signup-button" 93 + > 94 + <div class="flex flex-col mt-2"> 95 + <label for="email">email</label> 96 + <input 97 + type="email" 98 + id="email" 99 + name="email" 100 + tabindex="4" 101 + required 102 + placeholder="jason@bourne.co" 103 + /> 104 + </div> 105 + <span class="text-sm text-gray-500 mt-1"> 106 + You will receive an email with a code. Enter that, along with your 107 + desired username and password in the next page to complete your registration. 108 + </span> 109 + <button 110 + class="btn w-full my-2 mt-6" 111 + type="submit" 112 + id="signup-button" 113 + tabindex="7" 114 + > 115 + <span>sign up</span> 116 + </button> 117 + </form> 118 + </details> 119 + <p class="text-sm text-gray-500 mt-6"> 73 120 Join our <a href="https://chat.tangled.sh">Discord</a> or 74 121 IRC channel: 75 122 <a href="https://web.libera.chat/#tangled"
+3 -3
appview/pages/templates/user/repos.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 order-1 md:order-1"> 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 13 {{ template "user/fragments/profileCard" .Card }} 14 14 </div> 15 - <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 15 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 16 {{ block "ownRepos" . }}{{ end }} 17 17 </div> 18 18 </div>
+1 -5
appview/pipelines/pipelines.go
··· 11 11 12 12 "tangled.sh/tangled.sh/core/appview/config" 13 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/idresolver" 15 14 "tangled.sh/tangled.sh/core/appview/oauth" 16 15 "tangled.sh/tangled.sh/core/appview/pages" 17 16 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 17 "tangled.sh/tangled.sh/core/eventconsumer" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/log" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 22 23 23 "github.com/go-chi/chi/v5" 24 24 "github.com/gorilla/websocket" 25 - "github.com/posthog/posthog-go" 26 25 ) 27 26 28 27 type Pipelines struct { ··· 34 33 spindlestream *eventconsumer.Consumer 35 34 db *db.DB 36 35 enforcer *rbac.Enforcer 37 - posthog posthog.Client 38 36 logger *slog.Logger 39 37 } 40 38 ··· 46 44 idResolver *idresolver.Resolver, 47 45 db *db.DB, 48 46 config *config.Config, 49 - posthog posthog.Client, 50 47 enforcer *rbac.Enforcer, 51 48 ) *Pipelines { 52 49 logger := log.New("pipelines") ··· 58 55 config: config, 59 56 spindlestream: spindlestream, 60 57 db: db, 61 - posthog: posthog, 62 58 enforcer: enforcer, 63 59 logger: logger, 64 60 }
+131
appview/posthog/notifier.go
··· 1 + package posthog_service 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.sh/tangled.sh/core/appview/db" 9 + "tangled.sh/tangled.sh/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.OwnerDid, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: follow.UserDid, 104 + Event: "follow", 105 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 + }) 107 + if err != nil { 108 + log.Println("failed to enqueue posthog event:", err) 109 + } 110 + } 111 + 112 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 + err := n.client.Enqueue(posthog.Capture{ 114 + DistinctId: follow.UserDid, 115 + Event: "unfollow", 116 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 + }) 118 + if err != nil { 119 + log.Println("failed to enqueue posthog event:", err) 120 + } 121 + } 122 + 123 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 + err := n.client.Enqueue(posthog.Capture{ 125 + DistinctId: profile.Did, 126 + Event: "edit_profile", 127 + }) 128 + if err != nil { 129 + log.Println("failed to enqueue posthog event:", err) 130 + } 131 + }
+44 -41
appview/pulls/pulls.go
··· 14 14 "time" 15 15 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/config" 19 18 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 19 + "tangled.sh/tangled.sh/core/appview/notify" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/pages" 23 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/tid" 26 27 "tangled.sh/tangled.sh/core/types" 27 28 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 31 32 lexutil "github.com/bluesky-social/indigo/lex/util" 32 33 "github.com/go-chi/chi/v5" 33 34 "github.com/google/uuid" 34 - "github.com/posthog/posthog-go" 35 35 ) 36 36 37 37 type Pulls struct { ··· 41 41 idResolver *idresolver.Resolver 42 42 db *db.DB 43 43 config *config.Config 44 - posthog posthog.Client 44 + notifier notify.Notifier 45 45 } 46 46 47 47 func New( ··· 51 51 resolver *idresolver.Resolver, 52 52 db *db.DB, 53 53 config *config.Config, 54 - posthog posthog.Client, 54 + notifier notify.Notifier, 55 55 ) *Pulls { 56 56 return &Pulls{ 57 57 oauth: oauth, ··· 60 60 idResolver: resolver, 61 61 db: db, 62 62 config: config, 63 - posthog: posthog, 63 + notifier: notifier, 64 64 } 65 65 } 66 66 ··· 555 555 556 556 // we want to group all stacked PRs into just one list 557 557 stacks := make(map[string]db.Stack) 558 + var shas []string 558 559 n := 0 559 560 for _, p := range pulls { 561 + // store the sha for later 562 + shas = append(shas, p.LatestSha()) 560 563 // this PR is stacked 561 564 if p.StackId != "" { 562 565 // we have already seen this PR stack ··· 575 578 } 576 579 pulls = pulls[:n] 577 580 581 + repoInfo := f.RepoInfo(user) 582 + ps, err := db.GetPipelineStatuses( 583 + s.db, 584 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 585 + db.FilterEq("repo_name", repoInfo.Name), 586 + db.FilterEq("knot", repoInfo.Knot), 587 + db.FilterIn("sha", shas), 588 + ) 589 + if err != nil { 590 + log.Printf("failed to fetch pipeline statuses: %s", err) 591 + // non-fatal 592 + } 593 + m := make(map[string]db.Pipeline) 594 + for _, p := range ps { 595 + m[p.Sha] = p 596 + } 597 + 578 598 identsToResolve := make([]string, len(pulls)) 579 599 for i, pull := range pulls { 580 600 identsToResolve[i] = pull.OwnerDid ··· 596 616 DidHandleMap: didHandleMap, 597 617 FilteringBy: state, 598 618 Stacks: stacks, 619 + Pipelines: m, 599 620 }) 600 - return 601 621 } 602 622 603 623 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 668 688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 669 689 Collection: tangled.RepoPullCommentNSID, 670 690 Repo: user.Did, 671 - Rkey: appview.TID(), 691 + Rkey: tid.TID(), 672 692 Record: &lexutil.LexiconTypeDecoder{ 673 693 Val: &tangled.RepoPullComment{ 674 694 Repo: &atUri, ··· 685 705 return 686 706 } 687 707 688 - // Create the pull comment in the database with the commentAt field 689 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 708 + comment := &db.PullComment{ 690 709 OwnerDid: user.Did, 691 710 RepoAt: f.RepoAt.String(), 692 711 PullId: pull.PullId, 693 712 Body: body, 694 713 CommentAt: atResp.Uri, 695 714 SubmissionId: pull.Submissions[roundNumber].ID, 696 - }) 715 + } 716 + 717 + // Create the pull comment in the database with the commentAt field 718 + commentId, err := db.NewPullComment(tx, comment) 697 719 if err != nil { 698 720 log.Println("failed to create pull comment", err) 699 721 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 707 729 return 708 730 } 709 731 710 - if !s.config.Core.Dev { 711 - err = s.posthog.Enqueue(posthog.Capture{ 712 - DistinctId: user.Did, 713 - Event: "new_pull_comment", 714 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, 715 - }) 716 - if err != nil { 717 - log.Println("failed to enqueue posthog event:", err) 718 - } 719 - } 732 + s.notifier.NewPullComment(r.Context(), comment) 720 733 721 734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 722 735 return ··· 1045 1058 body = formatPatches[0].Body 1046 1059 } 1047 1060 1048 - rkey := appview.TID() 1061 + rkey := tid.TID() 1049 1062 initialSubmission := db.PullSubmission{ 1050 1063 Patch: patch, 1051 1064 SourceRev: sourceRev, 1052 1065 } 1053 - err = db.NewPull(tx, &db.Pull{ 1066 + pull := &db.Pull{ 1054 1067 Title: title, 1055 1068 Body: body, 1056 1069 TargetBranch: targetBranch, ··· 1061 1074 &initialSubmission, 1062 1075 }, 1063 1076 PullSource: pullSource, 1064 - }) 1077 + } 1078 + err = db.NewPull(tx, pull) 1065 1079 if err != nil { 1066 1080 log.Println("failed to create pull request", err) 1067 1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1101 1115 return 1102 1116 } 1103 1117 1104 - if !s.config.Core.Dev { 1105 - err = s.posthog.Enqueue(posthog.Capture{ 1106 - DistinctId: user.Did, 1107 - Event: "new_pull", 1108 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, 1109 - }) 1110 - if err != nil { 1111 - log.Println("failed to enqueue posthog event:", err) 1112 - } 1113 - } 1118 + s.notifier.NewPull(r.Context(), pull) 1114 1119 1115 1120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1116 1121 } ··· 1673 1678 } 1674 1679 1675 1680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1676 - return 1677 1681 } 1678 1682 1679 1683 func (s *Pulls) resubmitStackedPullHelper( ··· 1917 1921 } 1918 1922 1919 1923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1920 - return 1921 1924 } 1922 1925 1923 1926 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2041 2044 2042 2045 // auth filter: only owner or collaborators can close 2043 2046 roles := f.RolesInRepo(user) 2047 + isOwner := roles.IsOwner() 2044 2048 isCollaborator := roles.IsCollaborator() 2045 2049 isPullAuthor := user.Did == pull.OwnerDid 2046 - isCloseAllowed := isCollaborator || isPullAuthor 2050 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2047 2051 if !isCloseAllowed { 2048 2052 log.Println("failed to close pull") 2049 2053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2087 2091 } 2088 2092 2089 2093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2090 - return 2091 2094 } 2092 2095 2093 2096 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2109 2112 2110 2113 // auth filter: only owner or collaborators can close 2111 2114 roles := f.RolesInRepo(user) 2115 + isOwner := roles.IsOwner() 2112 2116 isCollaborator := roles.IsCollaborator() 2113 2117 isPullAuthor := user.Did == pull.OwnerDid 2114 - isCloseAllowed := isCollaborator || isPullAuthor 2118 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2115 2119 if !isCloseAllowed { 2116 2120 log.Println("failed to close pull") 2117 2121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2155 2159 } 2156 2160 2157 2161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2158 - return 2159 2162 } 2160 2163 2161 2164 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2181 2184 2182 2185 title := fp.Title 2183 2186 body := fp.Body 2184 - rkey := appview.TID() 2187 + rkey := tid.TID() 2185 2188 2186 2189 initialSubmission := db.PullSubmission{ 2187 2190 Patch: fp.Raw,
+2
appview/pulls/router.go
··· 44 44 r.Get("/", s.ResubmitPull) 45 45 r.Post("/", s.ResubmitPull) 46 46 }) 47 + // permissions here require us to know pull author 48 + // it is handled within the route 47 49 r.Post("/close", s.ClosePull) 48 50 r.Post("/reopen", s.ReopenPull) 49 51 // collaborators only
+2 -2
appview/repo/artifact.go
··· 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 "github.com/ipfs/go-cid" 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/db" 19 18 "tangled.sh/tangled.sh/core/appview/pages" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 20 "tangled.sh/tangled.sh/core/knotclient" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 "tangled.sh/tangled.sh/core/types" 23 23 ) 24 24 ··· 64 64 65 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 66 67 - rkey := appview.TID() 67 + rkey := tid.TID() 68 68 createdAt := time.Now() 69 69 70 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+2
appview/repo/index.go
··· 58 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 59 } 60 60 61 + sortFiles(result.Files) 62 + 61 63 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 62 64 if a.Name == result.Ref { 63 65 return -1
+416 -124
appview/repo/repo.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log" 11 + "log/slog" 11 12 "net/http" 12 13 "net/url" 13 - "path" 14 + "path/filepath" 14 15 "slices" 15 - "sort" 16 16 "strconv" 17 17 "strings" 18 18 "time" 19 19 20 20 "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 21 "tangled.sh/tangled.sh/core/appview/commitverify" 23 22 "tangled.sh/tangled.sh/core/appview/config" 24 23 "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/idresolver" 24 + "tangled.sh/tangled.sh/core/appview/notify" 26 25 "tangled.sh/tangled.sh/core/appview/oauth" 27 26 "tangled.sh/tangled.sh/core/appview/pages" 28 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 + "tangled.sh/tangled.sh/core/idresolver" 31 31 "tangled.sh/tangled.sh/core/knotclient" 32 32 "tangled.sh/tangled.sh/core/patchutil" 33 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 34 35 "tangled.sh/tangled.sh/core/types" 35 36 36 37 securejoin "github.com/cyphar/filepath-securejoin" 37 38 "github.com/go-chi/chi/v5" 38 39 "github.com/go-git/go-git/v5/plumbing" 39 - "github.com/posthog/posthog-go" 40 40 41 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 42 43 lexutil "github.com/bluesky-social/indigo/lex/util" 43 44 ) 44 45 ··· 51 52 spindlestream *eventconsumer.Consumer 52 53 db *db.DB 53 54 enforcer *rbac.Enforcer 54 - posthog posthog.Client 55 + notifier notify.Notifier 56 + logger *slog.Logger 55 57 } 56 58 57 59 func New( ··· 62 64 idResolver *idresolver.Resolver, 63 65 db *db.DB, 64 66 config *config.Config, 65 - posthog posthog.Client, 67 + notifier notify.Notifier, 66 68 enforcer *rbac.Enforcer, 69 + logger *slog.Logger, 67 70 ) *Repo { 68 71 return &Repo{oauth: oauth, 69 72 repoResolver: repoResolver, ··· 72 75 config: config, 73 76 spindlestream: spindlestream, 74 77 db: db, 75 - posthog: posthog, 78 + notifier: notifier, 76 79 enforcer: enforcer, 80 + logger: logger, 77 81 } 78 82 } 79 83 ··· 179 183 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 180 184 RepoInfo: f.RepoInfo(user), 181 185 }) 182 - return 183 186 } 184 187 185 188 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 374 377 375 378 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 376 379 // so we can safely redirect to the "parent" (which is the same file). 377 - if len(result.Files) == 0 && result.Parent == treePath { 380 + unescapedTreePath, _ := url.PathUnescape(treePath) 381 + if len(result.Files) == 0 && result.Parent == unescapedTreePath { 378 382 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 379 383 return 380 384 } ··· 389 393 } 390 394 } 391 395 392 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 393 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 396 + sortFiles(result.Files) 394 397 395 398 rp.pages.RepoTree(w, pages.RepoTreeParams{ 396 399 LoggedInUser: user, 397 400 BreadCrumbs: breadcrumbs, 398 - BaseTreeLink: baseTreeLink, 399 - BaseBlobLink: baseBlobLink, 401 + TreePath: treePath, 400 402 RepoInfo: f.RepoInfo(user), 401 403 RepoTreeResponse: result, 402 404 }) 403 - return 404 405 } 405 406 406 407 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 458 459 ArtifactMap: artifactMap, 459 460 DanglingArtifacts: danglingArtifacts, 460 461 }) 461 - return 462 462 } 463 463 464 464 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 480 480 return 481 481 } 482 482 483 - slices.SortFunc(result.Branches, func(a, b types.Branch) int { 484 - if a.IsDefault { 485 - return -1 486 - } 487 - if b.IsDefault { 488 - return 1 489 - } 490 - if a.Commit != nil && b.Commit != nil { 491 - if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 492 - return 1 493 - } else { 494 - return -1 495 - } 496 - } 497 - return strings.Compare(a.Name, b.Name) * -1 498 - }) 483 + sortBranches(result.Branches) 499 484 500 485 user := rp.oauth.GetUser(r) 501 486 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 503 488 RepoInfo: f.RepoInfo(user), 504 489 RepoBranchesResponse: *result, 505 490 }) 506 - return 507 491 } 508 492 509 493 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 554 538 showRendered = r.URL.Query().Get("code") != "true" 555 539 } 556 540 541 + var unsupported bool 542 + var isImage bool 543 + var isVideo bool 544 + var contentSrc string 545 + 546 + if result.IsBinary { 547 + ext := strings.ToLower(filepath.Ext(result.Path)) 548 + switch ext { 549 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 550 + isImage = true 551 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 552 + isVideo = true 553 + default: 554 + unsupported = true 555 + } 556 + 557 + // fetch the actual binary content like in RepoBlobRaw 558 + 559 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 560 + contentSrc = blobURL 561 + if !rp.config.Core.Dev { 562 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 563 + } 564 + } 565 + 557 566 user := rp.oauth.GetUser(r) 558 567 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 559 568 LoggedInUser: user, ··· 562 571 BreadCrumbs: breadcrumbs, 563 572 ShowRendered: showRendered, 564 573 RenderToggle: renderToggle, 574 + Unsupported: unsupported, 575 + IsImage: isImage, 576 + IsVideo: isVideo, 577 + ContentSrc: contentSrc, 565 578 }) 566 - return 567 579 } 568 580 569 581 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 570 582 f, err := rp.repoResolver.Resolve(r) 571 583 if err != nil { 572 584 log.Println("failed to get repo and knot", err) 585 + w.WriteHeader(http.StatusBadRequest) 573 586 return 574 587 } 575 588 ··· 580 593 if !rp.config.Core.Dev { 581 594 protocol = "https" 582 595 } 583 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 596 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 597 + resp, err := http.Get(blobURL) 584 598 if err != nil { 585 - log.Println("failed to reach knotserver", err) 599 + log.Println("failed to reach knotserver:", err) 600 + rp.pages.Error503(w) 586 601 return 587 602 } 603 + defer resp.Body.Close() 588 604 589 - body, err := io.ReadAll(resp.Body) 590 - if err != nil { 591 - log.Printf("Error reading response body: %v", err) 605 + if resp.StatusCode != http.StatusOK { 606 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 607 + w.WriteHeader(resp.StatusCode) 608 + _, _ = io.Copy(w, resp.Body) 592 609 return 593 610 } 594 611 595 - var result types.RepoBlobResponse 596 - err = json.Unmarshal(body, &result) 612 + contentType := resp.Header.Get("Content-Type") 613 + body, err := io.ReadAll(resp.Body) 597 614 if err != nil { 598 - log.Println("failed to parse response:", err) 615 + log.Printf("error reading response body from knotserver: %v", err) 616 + w.WriteHeader(http.StatusInternalServerError) 599 617 return 600 618 } 601 619 602 - if result.IsBinary { 603 - w.Header().Set("Content-Type", "application/octet-stream") 620 + if strings.Contains(contentType, "text/plain") { 621 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 622 + w.Write(body) 623 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 624 + w.Header().Set("Content-Type", contentType) 604 625 w.Write(body) 626 + } else { 627 + w.WriteHeader(http.StatusUnsupportedMediaType) 628 + w.Write([]byte("unsupported content type")) 605 629 return 606 630 } 607 - 608 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 609 - w.Write([]byte(result.Contents)) 610 - return 611 631 } 612 632 613 633 // modify the spindle configured for this repo 614 634 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 635 + user := rp.oauth.GetUser(r) 636 + l := rp.logger.With("handler", "EditSpindle") 637 + l = l.With("did", user.Did) 638 + l = l.With("handle", user.Handle) 639 + 640 + errorId := "operation-error" 641 + fail := func(msg string, err error) { 642 + l.Error(msg, "err", err) 643 + rp.pages.Notice(w, errorId, msg) 644 + } 645 + 615 646 f, err := rp.repoResolver.Resolve(r) 616 647 if err != nil { 617 - log.Println("failed to get repo and knot", err) 618 - w.WriteHeader(http.StatusBadRequest) 648 + fail("Failed to resolve repo. Try again later", err) 619 649 return 620 650 } 621 651 622 652 repoAt := f.RepoAt 623 653 rkey := repoAt.RecordKey().String() 624 654 if rkey == "" { 625 - log.Println("invalid aturi for repo", err) 626 - w.WriteHeader(http.StatusInternalServerError) 655 + fail("Failed to resolve repo. Try again later", err) 627 656 return 628 657 } 629 - 630 - user := rp.oauth.GetUser(r) 631 658 632 659 newSpindle := r.FormValue("spindle") 633 660 client, err := rp.oauth.AuthorizedClient(r) 634 661 if err != nil { 635 - log.Println("failed to get client") 636 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 662 + fail("Failed to authorize. Try again later.", err) 637 663 return 638 664 } 639 665 640 666 // ensure that this is a valid spindle for this user 641 667 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 642 668 if err != nil { 643 - log.Println("failed to get valid spindles") 644 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 669 + fail("Failed to find spindles. Try again later.", err) 645 670 return 646 671 } 647 672 648 673 if !slices.Contains(validSpindles, newSpindle) { 649 - log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles) 650 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 674 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 651 675 return 652 676 } 653 677 654 678 // optimistic update 655 679 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 656 680 if err != nil { 657 - log.Println("failed to perform update-spindle query", err) 658 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 681 + fail("Failed to update spindle. Try again later.", err) 659 682 return 660 683 } 661 684 662 685 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 663 686 if err != nil { 664 - // failed to get record 665 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 687 + fail("Failed to update spindle, no record found on PDS.", err) 666 688 return 667 689 } 668 690 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 683 705 }) 684 706 685 707 if err != nil { 686 - log.Println("failed to perform update-spindle query", err) 687 - // failed to get record 688 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.") 708 + fail("Failed to update spindle, unable to save to PDS.", err) 689 709 return 690 710 } 691 711 ··· 695 715 eventconsumer.NewSpindleSource(newSpindle), 696 716 ) 697 717 698 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 718 + rp.pages.HxRefresh(w) 699 719 } 700 720 701 721 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 722 + user := rp.oauth.GetUser(r) 723 + l := rp.logger.With("handler", "AddCollaborator") 724 + l = l.With("did", user.Did) 725 + l = l.With("handle", user.Handle) 726 + 702 727 f, err := rp.repoResolver.Resolve(r) 703 728 if err != nil { 704 - log.Println("failed to get repo and knot", err) 729 + l.Error("failed to get repo and knot", "err", err) 705 730 return 731 + } 732 + 733 + errorId := "add-collaborator-error" 734 + fail := func(msg string, err error) { 735 + l.Error(msg, "err", err) 736 + rp.pages.Notice(w, errorId, msg) 706 737 } 707 738 708 739 collaborator := r.FormValue("collaborator") 709 740 if collaborator == "" { 710 - http.Error(w, "malformed form", http.StatusBadRequest) 741 + fail("Invalid form.", nil) 711 742 return 712 743 } 744 + 745 + // remove a single leading `@`, to make @handle work with ResolveIdent 746 + collaborator = strings.TrimPrefix(collaborator, "@") 713 747 714 748 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 715 749 if err != nil { 716 - w.Write([]byte("failed to resolve collaborator did to a handle")) 750 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 751 + return 752 + } 753 + 754 + if collaboratorIdent.DID.String() == user.Did { 755 + fail("You seem to be adding yourself as a collaborator.", nil) 717 756 return 718 757 } 719 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 758 + l = l.With("collaborator", collaboratorIdent.Handle) 759 + l = l.With("knot", f.Knot) 720 760 721 - // TODO: create an atproto record for this 761 + // announce this relation into the firehose, store into owners' pds 762 + client, err := rp.oauth.AuthorizedClient(r) 763 + if err != nil { 764 + fail("Failed to write to PDS.", err) 765 + return 766 + } 767 + 768 + // emit a record 769 + currentUser := rp.oauth.GetUser(r) 770 + rkey := tid.TID() 771 + createdAt := time.Now() 772 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 773 + Collection: tangled.RepoCollaboratorNSID, 774 + Repo: currentUser.Did, 775 + Rkey: rkey, 776 + Record: &lexutil.LexiconTypeDecoder{ 777 + Val: &tangled.RepoCollaborator{ 778 + Subject: collaboratorIdent.DID.String(), 779 + Repo: string(f.RepoAt), 780 + CreatedAt: createdAt.Format(time.RFC3339), 781 + }}, 782 + }) 783 + // invalid record 784 + if err != nil { 785 + fail("Failed to write record to PDS.", err) 786 + return 787 + } 788 + l = l.With("at-uri", resp.Uri) 789 + l.Info("wrote record to PDS") 722 790 791 + l.Info("adding to knot") 723 792 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 724 793 if err != nil { 725 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 794 + fail("Failed to add to knot.", err) 726 795 return 727 796 } 728 797 729 798 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 730 799 if err != nil { 731 - log.Println("failed to create client to ", f.Knot) 800 + fail("Failed to add to knot.", err) 732 801 return 733 802 } 734 803 735 804 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 736 805 if err != nil { 737 - log.Printf("failed to make request to %s: %s", f.Knot, err) 806 + fail("Knot was unreachable.", err) 738 807 return 739 808 } 740 809 741 810 if ksResp.StatusCode != http.StatusNoContent { 742 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 811 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 743 812 return 744 813 } 745 814 746 815 tx, err := rp.db.BeginTx(r.Context(), nil) 747 816 if err != nil { 748 - log.Println("failed to start tx") 749 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 817 + fail("Failed to add collaborator.", err) 750 818 return 751 819 } 752 820 defer func() { 753 821 tx.Rollback() 754 822 err = rp.enforcer.E.LoadPolicy() 755 823 if err != nil { 756 - log.Println("failed to rollback policies") 824 + fail("Failed to add collaborator.", err) 757 825 } 758 826 }() 759 827 760 828 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 761 829 if err != nil { 762 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 830 + fail("Failed to add collaborator permissions.", err) 763 831 return 764 832 } 765 833 766 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 834 + err = db.AddCollaborator(rp.db, db.Collaborator{ 835 + Did: syntax.DID(currentUser.Did), 836 + Rkey: rkey, 837 + SubjectDid: collaboratorIdent.DID, 838 + RepoAt: f.RepoAt, 839 + Created: createdAt, 840 + }) 767 841 if err != nil { 768 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 842 + fail("Failed to add collaborator.", err) 769 843 return 770 844 } 771 845 772 846 err = tx.Commit() 773 847 if err != nil { 774 - log.Println("failed to commit changes", err) 775 - http.Error(w, err.Error(), http.StatusInternalServerError) 848 + fail("Failed to add collaborator.", err) 776 849 return 777 850 } 778 851 779 852 err = rp.enforcer.E.SavePolicy() 780 853 if err != nil { 781 - log.Println("failed to update ACLs", err) 782 - http.Error(w, err.Error(), http.StatusInternalServerError) 854 + fail("Failed to update collaborator permissions.", err) 783 855 return 784 856 } 785 857 786 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 787 - 858 + rp.pages.HxRefresh(w) 788 859 } 789 860 790 861 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 936 1007 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 937 1008 } 938 1009 939 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1010 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1011 + user := rp.oauth.GetUser(r) 1012 + l := rp.logger.With("handler", "Secrets") 1013 + l = l.With("handle", user.Handle) 1014 + l = l.With("did", user.Did) 1015 + 940 1016 f, err := rp.repoResolver.Resolve(r) 941 1017 if err != nil { 942 1018 log.Println("failed to get repo and knot", err) 943 1019 return 944 1020 } 945 1021 1022 + if f.Spindle == "" { 1023 + log.Println("empty spindle cannot add/rm secret", err) 1024 + return 1025 + } 1026 + 1027 + lxm := tangled.RepoAddSecretNSID 1028 + if r.Method == http.MethodDelete { 1029 + lxm = tangled.RepoRemoveSecretNSID 1030 + } 1031 + 1032 + spindleClient, err := rp.oauth.ServiceClient( 1033 + r, 1034 + oauth.WithService(f.Spindle), 1035 + oauth.WithLxm(lxm), 1036 + oauth.WithDev(rp.config.Core.Dev), 1037 + ) 1038 + if err != nil { 1039 + log.Println("failed to create spindle client", err) 1040 + return 1041 + } 1042 + 1043 + key := r.FormValue("key") 1044 + if key == "" { 1045 + w.WriteHeader(http.StatusBadRequest) 1046 + return 1047 + } 1048 + 946 1049 switch r.Method { 947 - case http.MethodGet: 948 - // for now, this is just pubkeys 949 - user := rp.oauth.GetUser(r) 950 - repoCollaborators, err := f.Collaborators(r.Context()) 951 - if err != nil { 952 - log.Println("failed to get collaborators", err) 953 - } 1050 + case http.MethodPut: 1051 + errorId := "add-secret-error" 954 1052 955 - isCollaboratorInviteAllowed := false 956 - if user != nil { 957 - ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 958 - if err == nil && ok { 959 - isCollaboratorInviteAllowed = true 960 - } 1053 + value := r.FormValue("value") 1054 + if value == "" { 1055 + w.WriteHeader(http.StatusBadRequest) 1056 + return 961 1057 } 962 1058 963 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1059 + err = tangled.RepoAddSecret( 1060 + r.Context(), 1061 + spindleClient, 1062 + &tangled.RepoAddSecret_Input{ 1063 + Repo: f.RepoAt.String(), 1064 + Key: key, 1065 + Value: value, 1066 + }, 1067 + ) 964 1068 if err != nil { 965 - log.Println("failed to create unsigned client", err) 1069 + l.Error("Failed to add secret.", "err", err) 1070 + rp.pages.Notice(w, errorId, "Failed to add secret.") 966 1071 return 967 1072 } 968 1073 969 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1074 + case http.MethodDelete: 1075 + errorId := "operation-error" 1076 + 1077 + err = tangled.RepoRemoveSecret( 1078 + r.Context(), 1079 + spindleClient, 1080 + &tangled.RepoRemoveSecret_Input{ 1081 + Repo: f.RepoAt.String(), 1082 + Key: key, 1083 + }, 1084 + ) 970 1085 if err != nil { 971 - log.Println("failed to reach knotserver", err) 1086 + l.Error("Failed to delete secret.", "err", err) 1087 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 972 1088 return 973 1089 } 1090 + } 974 1091 975 - // all spindles that this user is a member of 976 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 977 - if err != nil { 978 - log.Println("failed to fetch spindles", err) 979 - return 1092 + rp.pages.HxRefresh(w) 1093 + } 1094 + 1095 + type tab = map[string]any 1096 + 1097 + var ( 1098 + // would be great to have ordered maps right about now 1099 + settingsTabs []tab = []tab{ 1100 + {"Name": "general", "Icon": "sliders-horizontal"}, 1101 + {"Name": "access", "Icon": "users"}, 1102 + {"Name": "pipelines", "Icon": "layers-2"}, 1103 + } 1104 + ) 1105 + 1106 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1107 + tabVal := r.URL.Query().Get("tab") 1108 + if tabVal == "" { 1109 + tabVal = "general" 1110 + } 1111 + 1112 + switch tabVal { 1113 + case "general": 1114 + rp.generalSettings(w, r) 1115 + 1116 + case "access": 1117 + rp.accessSettings(w, r) 1118 + 1119 + case "pipelines": 1120 + rp.pipelineSettings(w, r) 1121 + } 1122 + 1123 + // user := rp.oauth.GetUser(r) 1124 + // repoCollaborators, err := f.Collaborators(r.Context()) 1125 + // if err != nil { 1126 + // log.Println("failed to get collaborators", err) 1127 + // } 1128 + 1129 + // isCollaboratorInviteAllowed := false 1130 + // if user != nil { 1131 + // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1132 + // if err == nil && ok { 1133 + // isCollaboratorInviteAllowed = true 1134 + // } 1135 + // } 1136 + 1137 + // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1138 + // if err != nil { 1139 + // log.Println("failed to create unsigned client", err) 1140 + // return 1141 + // } 1142 + 1143 + // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1144 + // if err != nil { 1145 + // log.Println("failed to reach knotserver", err) 1146 + // return 1147 + // } 1148 + 1149 + // // all spindles that this user is a member of 1150 + // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1151 + // if err != nil { 1152 + // log.Println("failed to fetch spindles", err) 1153 + // return 1154 + // } 1155 + 1156 + // var secrets []*tangled.RepoListSecrets_Secret 1157 + // if f.Spindle != "" { 1158 + // if spindleClient, err := rp.oauth.ServiceClient( 1159 + // r, 1160 + // oauth.WithService(f.Spindle), 1161 + // oauth.WithLxm(tangled.RepoListSecretsNSID), 1162 + // oauth.WithDev(rp.config.Core.Dev), 1163 + // ); err != nil { 1164 + // log.Println("failed to create spindle client", err) 1165 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1166 + // log.Println("failed to fetch secrets", err) 1167 + // } else { 1168 + // secrets = resp.Secrets 1169 + // } 1170 + // } 1171 + 1172 + // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1173 + // LoggedInUser: user, 1174 + // RepoInfo: f.RepoInfo(user), 1175 + // Collaborators: repoCollaborators, 1176 + // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1177 + // Branches: result.Branches, 1178 + // Spindles: spindles, 1179 + // CurrentSpindle: f.Spindle, 1180 + // Secrets: secrets, 1181 + // }) 1182 + } 1183 + 1184 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1185 + f, err := rp.repoResolver.Resolve(r) 1186 + user := rp.oauth.GetUser(r) 1187 + 1188 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1189 + if err != nil { 1190 + log.Println("failed to create unsigned client", err) 1191 + return 1192 + } 1193 + 1194 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1195 + if err != nil { 1196 + log.Println("failed to reach knotserver", err) 1197 + return 1198 + } 1199 + 1200 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1201 + LoggedInUser: user, 1202 + RepoInfo: f.RepoInfo(user), 1203 + Branches: result.Branches, 1204 + Tabs: settingsTabs, 1205 + Tab: "general", 1206 + }) 1207 + } 1208 + 1209 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1210 + f, err := rp.repoResolver.Resolve(r) 1211 + user := rp.oauth.GetUser(r) 1212 + 1213 + repoCollaborators, err := f.Collaborators(r.Context()) 1214 + if err != nil { 1215 + log.Println("failed to get collaborators", err) 1216 + } 1217 + 1218 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1219 + LoggedInUser: user, 1220 + RepoInfo: f.RepoInfo(user), 1221 + Tabs: settingsTabs, 1222 + Tab: "access", 1223 + Collaborators: repoCollaborators, 1224 + }) 1225 + } 1226 + 1227 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1228 + f, err := rp.repoResolver.Resolve(r) 1229 + user := rp.oauth.GetUser(r) 1230 + 1231 + // all spindles that the repo owner is a member of 1232 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1233 + if err != nil { 1234 + log.Println("failed to fetch spindles", err) 1235 + return 1236 + } 1237 + 1238 + var secrets []*tangled.RepoListSecrets_Secret 1239 + if f.Spindle != "" { 1240 + if spindleClient, err := rp.oauth.ServiceClient( 1241 + r, 1242 + oauth.WithService(f.Spindle), 1243 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1244 + oauth.WithDev(rp.config.Core.Dev), 1245 + ); err != nil { 1246 + log.Println("failed to create spindle client", err) 1247 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1248 + log.Println("failed to fetch secrets", err) 1249 + } else { 1250 + secrets = resp.Secrets 980 1251 } 1252 + } 981 1253 982 - rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 983 - LoggedInUser: user, 984 - RepoInfo: f.RepoInfo(user), 985 - Collaborators: repoCollaborators, 986 - IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 987 - Branches: result.Branches, 988 - Spindles: spindles, 989 - CurrentSpindle: f.Spindle, 1254 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1255 + return strings.Compare(a.Key, b.Key) 1256 + }) 1257 + 1258 + var dids []string 1259 + for _, s := range secrets { 1260 + dids = append(dids, s.CreatedBy) 1261 + } 1262 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1263 + 1264 + // convert to a more manageable form 1265 + var niceSecret []map[string]any 1266 + for id, s := range secrets { 1267 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1268 + niceSecret = append(niceSecret, map[string]any{ 1269 + "Id": id, 1270 + "Key": s.Key, 1271 + "CreatedAt": when, 1272 + "CreatedBy": resolvedIdents[id].Handle.String(), 990 1273 }) 991 1274 } 1275 + 1276 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1277 + LoggedInUser: user, 1278 + RepoInfo: f.RepoInfo(user), 1279 + Tabs: settingsTabs, 1280 + Tab: "pipelines", 1281 + Spindles: spindles, 1282 + CurrentSpindle: f.Spindle, 1283 + Secrets: niceSecret, 1284 + }) 992 1285 } 993 1286 994 1287 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1108 1401 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1109 1402 sourceAt := f.RepoAt.String() 1110 1403 1111 - rkey := appview.TID() 1404 + rkey := tid.TID() 1112 1405 repo := &db.Repo{ 1113 1406 Did: user.Did, 1114 1407 Name: forkName, ··· 1233 1526 return 1234 1527 } 1235 1528 branches := result.Branches 1236 - sort.Slice(branches, func(i int, j int) bool { 1237 - return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1238 - }) 1529 + 1530 + sortBranches(branches) 1239 1531 1240 1532 var defaultBranch string 1241 1533 for _, b := range branches {
+34
appview/repo/repo_util.go
··· 5 5 "crypto/rand" 6 6 "fmt" 7 7 "math/big" 8 + "slices" 9 + "sort" 10 + "strings" 8 11 9 12 "tangled.sh/tangled.sh/core/appview/db" 10 13 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 + "tangled.sh/tangled.sh/core/types" 11 15 12 16 "github.com/go-git/go-git/v5/plumbing/object" 13 17 ) 18 + 19 + func sortFiles(files []types.NiceTree) { 20 + sort.Slice(files, func(i, j int) bool { 21 + iIsFile := files[i].IsFile 22 + jIsFile := files[j].IsFile 23 + if iIsFile != jIsFile { 24 + return !iIsFile 25 + } 26 + return files[i].Name < files[j].Name 27 + }) 28 + } 29 + 30 + func sortBranches(branches []types.Branch) { 31 + slices.SortFunc(branches, func(a, b types.Branch) int { 32 + if a.IsDefault { 33 + return -1 34 + } 35 + if b.IsDefault { 36 + return 1 37 + } 38 + if a.Commit != nil && b.Commit != nil { 39 + if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 40 + return 1 41 + } else { 42 + return -1 43 + } 44 + } 45 + return strings.Compare(a.Name, b.Name) 46 + }) 47 + } 14 48 15 49 func uniqueEmails(commits []*object.Commit) []string { 16 50 emails := make(map[string]struct{})
+2
appview/repo/router.go
··· 74 74 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 75 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 76 r.Put("/branches/default", rp.SetDefaultBranch) 77 + r.Put("/secrets", rp.Secrets) 78 + r.Delete("/secrets", rp.Secrets) 77 79 }) 78 80 }) 79 81
+5 -4
appview/reporesolver/resolver.go
··· 17 17 "github.com/go-chi/chi/v5" 18 18 "tangled.sh/tangled.sh/core/appview/config" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/pages" 23 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/rbac" 26 26 ) ··· 149 149 for _, item := range repoCollaborators { 150 150 // currently only two roles: owner and member 151 151 var role string 152 - if item[3] == "repo:owner" { 152 + switch item[3] { 153 + case "repo:owner": 153 154 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 155 + case "repo:collaborator": 155 156 role = "collaborator" 156 - } else { 157 + default: 157 158 continue 158 159 } 159 160
+2 -2
appview/settings/settings.go
··· 12 12 13 13 "github.com/go-chi/chi/v5" 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview" 16 15 "tangled.sh/tangled.sh/core/appview/config" 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" 20 19 "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 366 return 367 367 } 368 368 369 - rkey := appview.TID() 369 + rkey := tid.TID() 370 370 371 371 tx, err := s.Db.Begin() 372 372 if err != nil {
+104
appview/signup/requests.go
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
+249
appview/signup/signup.go
··· 1 + package signup 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/posthog/posthog-go" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/dns" 16 + "tangled.sh/tangled.sh/core/appview/email" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/state/userutil" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + ) 22 + 23 + type Signup struct { 24 + config *config.Config 25 + db *db.DB 26 + cf *dns.Cloudflare 27 + posthog posthog.Client 28 + xrpc *xrpcclient.Client 29 + idResolver *idresolver.Resolver 30 + pages *pages.Pages 31 + l *slog.Logger 32 + disallowedNicknames map[string]bool 33 + } 34 + 35 + func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 36 + var cf *dns.Cloudflare 37 + if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 38 + var err error 39 + cf, err = dns.NewCloudflare(cfg) 40 + if err != nil { 41 + l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 42 + } 43 + } 44 + 45 + disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + 47 + return &Signup{ 48 + config: cfg, 49 + db: database, 50 + posthog: pc, 51 + idResolver: idResolver, 52 + cf: cf, 53 + pages: pages, 54 + l: l, 55 + disallowedNicknames: disallowedNicknames, 56 + } 57 + } 58 + 59 + func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 60 + disallowed := make(map[string]bool) 61 + 62 + if filepath == "" { 63 + logger.Debug("no disallowed nicknames file configured") 64 + return disallowed 65 + } 66 + 67 + file, err := os.Open(filepath) 68 + if err != nil { 69 + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 70 + return disallowed 71 + } 72 + defer file.Close() 73 + 74 + scanner := bufio.NewScanner(file) 75 + lineNum := 0 76 + for scanner.Scan() { 77 + lineNum++ 78 + line := strings.TrimSpace(scanner.Text()) 79 + if line == "" || strings.HasPrefix(line, "#") { 80 + continue // skip empty lines and comments 81 + } 82 + 83 + nickname := strings.ToLower(line) 84 + if userutil.IsValidSubdomain(nickname) { 85 + disallowed[nickname] = true 86 + } else { 87 + logger.Warn("invalid nickname format in disallowed nicknames file", 88 + "file", filepath, "line", lineNum, "nickname", nickname) 89 + } 90 + } 91 + 92 + if err := scanner.Err(); err != nil { 93 + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 94 + } 95 + 96 + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 97 + return disallowed 98 + } 99 + 100 + // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 101 + func (s *Signup) isNicknameAllowed(nickname string) bool { 102 + return !s.disallowedNicknames[strings.ToLower(nickname)] 103 + } 104 + 105 + func (s *Signup) Router() http.Handler { 106 + r := chi.NewRouter() 107 + r.Post("/", s.signup) 108 + r.Get("/complete", s.complete) 109 + r.Post("/complete", s.complete) 110 + 111 + return r 112 + } 113 + 114 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 115 + if s.cf == nil { 116 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 117 + } 118 + emailId := r.FormValue("email") 119 + 120 + if !email.IsValidEmail(emailId) { 121 + s.pages.Notice(w, "login-msg", "Invalid email address.") 122 + return 123 + } 124 + 125 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 126 + if err != nil { 127 + s.l.Error("failed to check email existence", "error", err) 128 + s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 129 + return 130 + } 131 + if exists { 132 + s.pages.Notice(w, "login-msg", "Email already exists.") 133 + return 134 + } 135 + 136 + code, err := s.inviteCodeRequest() 137 + if err != nil { 138 + s.l.Error("failed to create invite code", "error", err) 139 + s.pages.Notice(w, "login-msg", "Failed to create invite code.") 140 + return 141 + } 142 + 143 + em := email.Email{ 144 + APIKey: s.config.Resend.ApiKey, 145 + From: s.config.Resend.SentFrom, 146 + To: emailId, 147 + Subject: "Verify your Tangled account", 148 + Text: `Copy and paste this code below to verify your account on Tangled. 149 + ` + code, 150 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 151 + <p><code>` + code + `</code></p>`, 152 + } 153 + 154 + err = email.SendEmail(em) 155 + if err != nil { 156 + s.l.Error("failed to send email", "error", err) 157 + s.pages.Notice(w, "login-msg", "Failed to send email.") 158 + return 159 + } 160 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 161 + Email: emailId, 162 + InviteCode: code, 163 + }) 164 + if err != nil { 165 + s.l.Error("failed to add inflight signup", "error", err) 166 + s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 167 + return 168 + } 169 + 170 + s.pages.HxRedirect(w, "/signup/complete") 171 + } 172 + 173 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 174 + switch r.Method { 175 + case http.MethodGet: 176 + s.pages.CompleteSignup(w, pages.SignupParams{}) 177 + case http.MethodPost: 178 + username := r.FormValue("username") 179 + password := r.FormValue("password") 180 + code := r.FormValue("code") 181 + 182 + if !userutil.IsValidSubdomain(username) { 183 + s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ€“63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.") 184 + return 185 + } 186 + 187 + if !s.isNicknameAllowed(username) { 188 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 189 + return 190 + } 191 + 192 + email, err := db.GetEmailForCode(s.db, code) 193 + if err != nil { 194 + s.l.Error("failed to get email for code", "error", err) 195 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 196 + return 197 + } 198 + 199 + did, err := s.createAccountRequest(username, password, email, code) 200 + if err != nil { 201 + s.l.Error("failed to create account", "error", err) 202 + s.pages.Notice(w, "signup-error", err.Error()) 203 + return 204 + } 205 + 206 + if s.cf == nil { 207 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 208 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 209 + return 210 + } 211 + 212 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 213 + Type: "TXT", 214 + Name: "_atproto." + username, 215 + Content: "did=" + did, 216 + TTL: 6400, 217 + Proxied: false, 218 + }) 219 + if err != nil { 220 + s.l.Error("failed to create DNS record", "error", err) 221 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 222 + return 223 + } 224 + 225 + err = db.AddEmail(s.db, db.Email{ 226 + Did: did, 227 + Address: email, 228 + Verified: true, 229 + Primary: true, 230 + }) 231 + if err != nil { 232 + s.l.Error("failed to add email", "error", err) 233 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 234 + return 235 + } 236 + 237 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 238 + <a class="underline text-black dark:text-white" href="/login">login</a> 239 + with <code>%s.tngl.sh</code>.`, username)) 240 + 241 + go func() { 242 + err := db.DeleteInflightSignup(s.db, email) 243 + if err != nil { 244 + s.l.Error("failed to delete inflight signup", "error", err) 245 + } 246 + }() 247 + return 248 + } 249 + }
+16 -8
appview/spindles/spindles.go
··· 10 10 11 11 "github.com/go-chi/chi/v5" 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 13 "tangled.sh/tangled.sh/core/appview/config" 15 14 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 15 "tangled.sh/tangled.sh/core/appview/middleware" 18 16 "tangled.sh/tangled.sh/core/appview/oauth" 19 17 "tangled.sh/tangled.sh/core/appview/pages" 20 18 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 + "tangled.sh/tangled.sh/core/idresolver" 21 20 "tangled.sh/tangled.sh/core/rbac" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 114 114 } 115 115 116 116 identsToResolve := make([]string, len(members)) 117 - for i, member := range members { 118 - identsToResolve[i] = member 119 - } 117 + copy(identsToResolve, members) 120 118 resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 121 119 didHandleMap := make(map[string]string) 122 120 for _, identity := range resolvedIds { ··· 258 256 259 257 // ok 260 258 s.Pages.HxRefresh(w) 261 - return 262 259 } 263 260 264 261 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 306 303 s.Enforcer.E.LoadPolicy() 307 304 }() 308 305 306 + // remove spindle members first 307 + err = db.RemoveSpindleMember( 308 + tx, 309 + db.FilterEq("did", user.Did), 310 + db.FilterEq("instance", instance), 311 + ) 312 + if err != nil { 313 + l.Error("failed to remove spindle members", "err", err) 314 + fail() 315 + return 316 + } 317 + 309 318 err = db.DeleteSpindle( 310 319 tx, 311 320 db.FilterEq("owner", user.Did), ··· 524 533 s.Enforcer.E.LoadPolicy() 525 534 }() 526 535 527 - rkey := appview.TID() 536 + rkey := tid.TID() 528 537 529 538 // add member to db 530 539 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 711 720 712 721 // ok 713 722 s.Pages.HxRefresh(w) 714 - return 715 723 }
+13 -26
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/posthog/posthog-go" 11 10 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 12 "tangled.sh/tangled.sh/core/appview/pages" 13 + "tangled.sh/tangled.sh/core/tid" 15 14 ) 16 15 17 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 41 switch r.Method { 43 42 case http.MethodPost: 44 43 createdAt := time.Now().Format(time.RFC3339) 45 - rkey := appview.TID() 44 + rkey := tid.TID() 46 45 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 46 Collection: tangled.GraphFollowNSID, 48 47 Repo: currentUser.Did, ··· 58 57 return 59 58 } 60 59 61 - err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey) 60 + log.Println("created atproto record: ", resp.Uri) 61 + 62 + follow := &db.Follow{ 63 + UserDid: currentUser.Did, 64 + SubjectDid: subjectIdent.DID.String(), 65 + Rkey: rkey, 66 + } 67 + 68 + err = db.AddFollow(s.db, follow) 62 69 if err != nil { 63 70 log.Println("failed to follow", err) 64 71 return 65 72 } 66 73 67 - log.Println("created atproto record: ", resp.Uri) 74 + s.notifier.NewFollow(r.Context(), follow) 68 75 69 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 70 77 UserDid: subjectIdent.DID.String(), 71 78 FollowStatus: db.IsFollowing, 72 79 }) 73 80 74 - if !s.config.Core.Dev { 75 - err = s.posthog.Enqueue(posthog.Capture{ 76 - DistinctId: currentUser.Did, 77 - Event: "follow", 78 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 79 - }) 80 - if err != nil { 81 - log.Println("failed to enqueue posthog event:", err) 82 - } 83 - } 84 - 85 81 return 86 82 case http.MethodDelete: 87 83 // find the record in the db ··· 113 109 FollowStatus: db.IsNotFollowing, 114 110 }) 115 111 116 - if !s.config.Core.Dev { 117 - err = s.posthog.Enqueue(posthog.Capture{ 118 - DistinctId: currentUser.Did, 119 - Event: "unfollow", 120 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 121 - }) 122 - if err != nil { 123 - log.Println("failed to enqueue posthog event:", err) 124 - } 125 - } 112 + s.notifier.DeleteFollow(r.Context(), follow) 126 113 127 114 return 128 115 }
+1 -13
appview/state/profile.go
··· 16 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 18 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 19 "tangled.sh/tangled.sh/core/api/tangled" 21 20 "tangled.sh/tangled.sh/core/appview/db" 22 21 "tangled.sh/tangled.sh/core/appview/pages" ··· 266 265 } 267 266 268 267 s.updateProfile(profile, w, r) 269 - return 270 268 } 271 269 272 270 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 306 304 profile.PinnedRepos = pinnedRepos 307 305 308 306 s.updateProfile(profile, w, r) 309 - return 310 307 } 311 308 312 309 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 371 368 return 372 369 } 373 370 374 - if !s.config.Core.Dev { 375 - err = s.posthog.Enqueue(posthog.Capture{ 376 - DistinctId: user.Did, 377 - Event: "edit_profile", 378 - }) 379 - if err != nil { 380 - log.Println("failed to enqueue posthog event:", err) 381 - } 382 - } 371 + s.notifier.UpdateProfile(r.Context(), profile) 383 372 384 373 s.pages.HxRedirect(w, "/"+user.Did) 385 - return 386 374 } 387 375 388 376 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+8 -8
appview/state/reaction.go
··· 10 10 11 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 13 "tangled.sh/tangled.sh/core/appview/db" 15 14 "tangled.sh/tangled.sh/core/appview/pages" 15 + "tangled.sh/tangled.sh/core/tid" 16 16 ) 17 17 18 18 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 45 45 switch r.Method { 46 46 case http.MethodPost: 47 47 createdAt := time.Now().Format(time.RFC3339) 48 - rkey := appview.TID() 48 + rkey := tid.TID() 49 49 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 50 Collection: tangled.FeedReactionNSID, 51 51 Repo: currentUser.Did, ··· 77 77 log.Println("created atproto record: ", resp.Uri) 78 78 79 79 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 - ThreadAt: subjectUri, 81 - Kind: reactionKind, 82 - Count: count, 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 83 IsReacted: true, 84 84 }) 85 85 ··· 115 115 } 116 116 117 117 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 - ThreadAt: subjectUri, 119 - Kind: reactionKind, 120 - Count: count, 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 121 IsReacted: false, 122 122 }) 123 123
+16 -4
appview/state/router.go
··· 14 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 15 "tangled.sh/tangled.sh/core/appview/repo" 16 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 17 18 "tangled.sh/tangled.sh/core/appview/spindles" 18 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 19 20 "tangled.sh/tangled.sh/core/log" ··· 137 138 r.Mount("/settings", s.SettingsRouter()) 138 139 r.Mount("/knots", s.KnotsRouter(mw)) 139 140 r.Mount("/spindles", s.SpindlesRouter()) 141 + r.Mount("/signup", s.SignupRouter()) 140 142 r.Mount("/", s.OAuthRouter()) 141 143 142 144 r.Get("/keys/{user}", s.Keys) 145 + r.Get("/terms", s.TermsOfService) 146 + r.Get("/privacy", s.PrivacyPolicy) 143 147 144 148 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 145 149 s.pages.Error404(w) ··· 198 202 } 199 203 200 204 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 201 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 205 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 202 206 return issues.Router(mw) 203 207 } 204 208 205 209 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 206 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 210 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 207 211 return pulls.Router(mw) 208 212 } 209 213 210 214 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 211 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 215 + logger := log.New("repo") 216 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 212 217 return repo.Router(mw) 213 218 } 214 219 215 220 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 216 - pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 221 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 217 222 return pipes.Router(mw) 218 223 } 224 + 225 + func (s *State) SignupRouter() http.Handler { 226 + logger := log.New("signup") 227 + 228 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 229 + return sig.Router() 230 + }
+15 -29
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "github.com/posthog/posthog-go" 12 11 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 12 "tangled.sh/tangled.sh/core/appview/db" 15 13 "tangled.sh/tangled.sh/core/appview/pages" 14 + "tangled.sh/tangled.sh/core/tid" 16 15 ) 17 16 18 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 38 switch r.Method { 40 39 case http.MethodPost: 41 40 createdAt := time.Now().Format(time.RFC3339) 42 - rkey := appview.TID() 41 + rkey := tid.TID() 43 42 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 43 Collection: tangled.FeedStarNSID, 45 44 Repo: currentUser.Did, ··· 54 53 log.Println("failed to create atproto record", err) 55 54 return 56 55 } 56 + log.Println("created atproto record: ", resp.Uri) 57 57 58 - err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey) 58 + star := &db.Star{ 59 + StarredByDid: currentUser.Did, 60 + RepoAt: subjectUri, 61 + Rkey: rkey, 62 + } 63 + 64 + err = db.AddStar(s.db, star) 59 65 if err != nil { 60 66 log.Println("failed to star", err) 61 67 return ··· 66 72 log.Println("failed to get star count for ", subjectUri) 67 73 } 68 74 69 - log.Println("created atproto record: ", resp.Uri) 75 + s.notifier.NewStar(r.Context(), star) 70 76 71 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 77 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 72 78 IsStarred: true, 73 79 RepoAt: subjectUri, 74 80 Stats: db.RepoStats{ ··· 76 82 }, 77 83 }) 78 84 79 - if !s.config.Core.Dev { 80 - err = s.posthog.Enqueue(posthog.Capture{ 81 - DistinctId: currentUser.Did, 82 - Event: "star", 83 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 84 - }) 85 - if err != nil { 86 - log.Println("failed to enqueue posthog event:", err) 87 - } 88 - } 89 - 90 85 return 91 86 case http.MethodDelete: 92 87 // find the record in the db ··· 119 114 return 120 115 } 121 116 122 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 117 + s.notifier.DeleteStar(r.Context(), star) 118 + 119 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 123 120 IsStarred: false, 124 121 RepoAt: subjectUri, 125 122 Stats: db.RepoStats{ 126 123 StarCount: starCount, 127 124 }, 128 125 }) 129 - 130 - if !s.config.Core.Dev { 131 - err = s.posthog.Enqueue(posthog.Capture{ 132 - DistinctId: currentUser.Did, 133 - Event: "unstar", 134 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 135 - }) 136 - if err != nil { 137 - log.Println("failed to enqueue posthog event:", err) 138 - } 139 - } 140 126 141 127 return 142 128 }
+27 -356
appview/state/state.go
··· 10 10 "time" 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 13 lexutil "github.com/bluesky-social/indigo/lex/util" 15 14 securejoin "github.com/cyphar/filepath-securejoin" 16 15 "github.com/go-chi/chi/v5" ··· 21 20 "tangled.sh/tangled.sh/core/appview/cache/session" 22 21 "tangled.sh/tangled.sh/core/appview/config" 23 22 "tangled.sh/tangled.sh/core/appview/db" 24 - "tangled.sh/tangled.sh/core/appview/idresolver" 23 + "tangled.sh/tangled.sh/core/appview/notify" 25 24 "tangled.sh/tangled.sh/core/appview/oauth" 26 25 "tangled.sh/tangled.sh/core/appview/pages" 26 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 + "tangled.sh/tangled.sh/core/idresolver" 29 30 "tangled.sh/tangled.sh/core/jetstream" 30 31 "tangled.sh/tangled.sh/core/knotclient" 31 32 tlog "tangled.sh/tangled.sh/core/log" 32 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 33 35 ) 34 36 35 37 type State struct { 36 38 db *db.DB 39 + notifier notify.Notifier 37 40 oauth *oauth.OAuth 38 41 enforcer *rbac.Enforcer 39 - tidClock syntax.TIDClock 40 42 pages *pages.Pages 41 43 sess *session.SessionStore 42 44 idResolver *idresolver.Resolver ··· 59 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 60 62 } 61 63 62 - clock := syntax.NewTIDClock(0) 63 - 64 64 pgs := pages.NewPages(config) 65 65 66 - res, err := idresolver.RedisResolver(config.Redis) 66 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 67 if err != nil { 68 68 log.Printf("failed to create redis resolver: %v", err) 69 69 res = idresolver.DefaultResolver() ··· 131 131 } 132 132 spindlestream.Start(ctx) 133 133 134 + var notifiers []notify.Notifier 135 + if !config.Core.Dev { 136 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 137 + } 138 + notifier := notify.NewMergedNotifier(notifiers...) 139 + 134 140 state := &State{ 135 141 d, 142 + notifier, 136 143 oauth, 137 144 enforcer, 138 - clock, 139 145 pgs, 140 146 sess, 141 147 res, ··· 150 156 return state, nil 151 157 } 152 158 153 - func TID(c *syntax.TIDClock) string { 154 - return c.Next().String() 159 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 160 + user := s.oauth.GetUser(r) 161 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 162 + LoggedInUser: user, 163 + }) 164 + } 165 + 166 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 167 + user := s.oauth.GetUser(r) 168 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 169 + LoggedInUser: user, 170 + }) 155 171 } 156 172 157 173 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 198 214 return 199 215 } 200 216 201 - // requires auth 202 - // func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 203 - // switch r.Method { 204 - // case http.MethodGet: 205 - // // list open registrations under this did 206 - // 207 - // return 208 - // case http.MethodPost: 209 - // session, err := s.oauth.Stores().Get(r, oauth.SessionName) 210 - // if err != nil || session.IsNew { 211 - // log.Println("unauthorized attempt to generate registration key") 212 - // http.Error(w, "Forbidden", http.StatusUnauthorized) 213 - // return 214 - // } 215 - // 216 - // did := session.Values[oauth.SessionDid].(string) 217 - // 218 - // // check if domain is valid url, and strip extra bits down to just host 219 - // domain := r.FormValue("domain") 220 - // if domain == "" { 221 - // http.Error(w, "Invalid form", http.StatusBadRequest) 222 - // return 223 - // } 224 - // 225 - // key, err := db.GenerateRegistrationKey(s.db, domain, did) 226 - // 227 - // if err != nil { 228 - // log.Println(err) 229 - // http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 230 - // return 231 - // } 232 - // 233 - // w.Write([]byte(key)) 234 - // } 235 - // } 236 - 237 217 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 238 218 user := chi.URLParam(r, "user") 239 219 user = strings.TrimPrefix(user, "@") ··· 266 246 } 267 247 } 268 248 269 - // create a signed request and check if a node responds to that 270 - // func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 271 - // user := s.oauth.GetUser(r) 272 - // 273 - // noticeId := "operation-error" 274 - // defaultErr := "Failed to register spindle. Try again later." 275 - // fail := func() { 276 - // s.pages.Notice(w, noticeId, defaultErr) 277 - // } 278 - // 279 - // domain := chi.URLParam(r, "domain") 280 - // if domain == "" { 281 - // http.Error(w, "malformed url", http.StatusBadRequest) 282 - // return 283 - // } 284 - // log.Println("checking ", domain) 285 - // 286 - // secret, err := db.GetRegistrationKey(s.db, domain) 287 - // if err != nil { 288 - // log.Printf("no key found for domain %s: %s\n", domain, err) 289 - // return 290 - // } 291 - // 292 - // client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 293 - // if err != nil { 294 - // log.Println("failed to create client to ", domain) 295 - // } 296 - // 297 - // resp, err := client.Init(user.Did) 298 - // if err != nil { 299 - // w.Write([]byte("no dice")) 300 - // log.Println("domain was unreachable after 5 seconds") 301 - // return 302 - // } 303 - // 304 - // if resp.StatusCode == http.StatusConflict { 305 - // log.Println("status conflict", resp.StatusCode) 306 - // w.Write([]byte("already registered, sorry!")) 307 - // return 308 - // } 309 - // 310 - // if resp.StatusCode != http.StatusNoContent { 311 - // log.Println("status nok", resp.StatusCode) 312 - // w.Write([]byte("no dice")) 313 - // return 314 - // } 315 - // 316 - // // verify response mac 317 - // signature := resp.Header.Get("X-Signature") 318 - // signatureBytes, err := hex.DecodeString(signature) 319 - // if err != nil { 320 - // return 321 - // } 322 - // 323 - // expectedMac := hmac.New(sha256.New, []byte(secret)) 324 - // expectedMac.Write([]byte("ok")) 325 - // 326 - // if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 327 - // log.Printf("response body signature mismatch: %x\n", signatureBytes) 328 - // return 329 - // } 330 - // 331 - // tx, err := s.db.BeginTx(r.Context(), nil) 332 - // if err != nil { 333 - // log.Println("failed to start tx", err) 334 - // http.Error(w, err.Error(), http.StatusInternalServerError) 335 - // return 336 - // } 337 - // defer func() { 338 - // tx.Rollback() 339 - // err = s.enforcer.E.LoadPolicy() 340 - // if err != nil { 341 - // log.Println("failed to rollback policies") 342 - // } 343 - // }() 344 - // 345 - // // mark as registered 346 - // err = db.Register(tx, domain) 347 - // if err != nil { 348 - // log.Println("failed to register domain", err) 349 - // http.Error(w, err.Error(), http.StatusInternalServerError) 350 - // return 351 - // } 352 - // 353 - // // set permissions for this did as owner 354 - // reg, err := db.RegistrationByDomain(tx, domain) 355 - // if err != nil { 356 - // log.Println("failed to register domain", err) 357 - // http.Error(w, err.Error(), http.StatusInternalServerError) 358 - // return 359 - // } 360 - // 361 - // // add basic acls for this domain 362 - // err = s.enforcer.AddKnot(domain) 363 - // if err != nil { 364 - // log.Println("failed to setup owner of domain", err) 365 - // http.Error(w, err.Error(), http.StatusInternalServerError) 366 - // return 367 - // } 368 - // 369 - // // add this did as owner of this domain 370 - // err = s.enforcer.AddKnotOwner(domain, reg.ByDid) 371 - // if err != nil { 372 - // log.Println("failed to setup owner of domain", err) 373 - // http.Error(w, err.Error(), http.StatusInternalServerError) 374 - // return 375 - // } 376 - // 377 - // err = tx.Commit() 378 - // if err != nil { 379 - // log.Println("failed to commit changes", err) 380 - // http.Error(w, err.Error(), http.StatusInternalServerError) 381 - // return 382 - // } 383 - // 384 - // err = s.enforcer.E.SavePolicy() 385 - // if err != nil { 386 - // log.Println("failed to update ACLs", err) 387 - // http.Error(w, err.Error(), http.StatusInternalServerError) 388 - // return 389 - // } 390 - // 391 - // // add this knot to knotstream 392 - // go s.knotstream.AddSource( 393 - // context.Background(), 394 - // eventconsumer.NewKnotSource(domain), 395 - // ) 396 - // 397 - // w.Write([]byte("check success")) 398 - // } 399 - 400 - // func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 401 - // domain := chi.URLParam(r, "domain") 402 - // if domain == "" { 403 - // http.Error(w, "malformed url", http.StatusBadRequest) 404 - // return 405 - // } 406 - // 407 - // user := s.oauth.GetUser(r) 408 - // reg, err := db.RegistrationByDomain(s.db, domain) 409 - // if err != nil { 410 - // w.Write([]byte("failed to pull up registration info")) 411 - // return 412 - // } 413 - // 414 - // var members []string 415 - // if reg.Registered != nil { 416 - // members, err = s.enforcer.GetUserByRole("server:member", domain) 417 - // if err != nil { 418 - // w.Write([]byte("failed to fetch member list")) 419 - // return 420 - // } 421 - // } 422 - // 423 - // var didsToResolve []string 424 - // for _, m := range members { 425 - // didsToResolve = append(didsToResolve, m) 426 - // } 427 - // didsToResolve = append(didsToResolve, reg.ByDid) 428 - // resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 429 - // didHandleMap := make(map[string]string) 430 - // for _, identity := range resolvedIds { 431 - // if !identity.Handle.IsInvalidHandle() { 432 - // didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 433 - // } else { 434 - // didHandleMap[identity.DID.String()] = identity.DID.String() 435 - // } 436 - // } 437 - // 438 - // ok, err := s.enforcer.IsKnotOwner(user.Did, domain) 439 - // isOwner := err == nil && ok 440 - // 441 - // p := pages.KnotParams{ 442 - // LoggedInUser: user, 443 - // DidHandleMap: didHandleMap, 444 - // Registration: reg, 445 - // Members: members, 446 - // IsOwner: isOwner, 447 - // } 448 - // 449 - // s.pages.Knot(w, p) 450 - // } 451 - 452 - // get knots registered by this user 453 - // func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 454 - // // for now, this is just pubkeys 455 - // user := s.oauth.GetUser(r) 456 - // registrations, err := db.RegistrationsByDid(s.db, user.Did) 457 - // if err != nil { 458 - // log.Println(err) 459 - // } 460 - // 461 - // s.pages.Knots(w, pages.KnotsParams{ 462 - // LoggedInUser: user, 463 - // Registrations: registrations, 464 - // }) 465 - // } 466 - 467 - // list members of domain, requires auth and requires owner status 468 - // func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 469 - // domain := chi.URLParam(r, "domain") 470 - // if domain == "" { 471 - // http.Error(w, "malformed url", http.StatusBadRequest) 472 - // return 473 - // } 474 - // 475 - // // list all members for this domain 476 - // memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 477 - // if err != nil { 478 - // w.Write([]byte("failed to fetch member list")) 479 - // return 480 - // } 481 - // 482 - // w.Write([]byte(strings.Join(memberDids, "\n"))) 483 - // return 484 - // } 485 - 486 - // add member to domain, requires auth and requires invite access 487 - // func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 488 - // domain := chi.URLParam(r, "domain") 489 - // if domain == "" { 490 - // http.Error(w, "malformed url", http.StatusBadRequest) 491 - // return 492 - // } 493 - // 494 - // subjectIdentifier := r.FormValue("subject") 495 - // if subjectIdentifier == "" { 496 - // http.Error(w, "malformed form", http.StatusBadRequest) 497 - // return 498 - // } 499 - // 500 - // subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 501 - // if err != nil { 502 - // w.Write([]byte("failed to resolve member did to a handle")) 503 - // return 504 - // } 505 - // log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 506 - // 507 - // // announce this relation into the firehose, store into owners' pds 508 - // client, err := s.oauth.AuthorizedClient(r) 509 - // if err != nil { 510 - // http.Error(w, "failed to authorize client", http.StatusInternalServerError) 511 - // return 512 - // } 513 - // currentUser := s.oauth.GetUser(r) 514 - // createdAt := time.Now().Format(time.RFC3339) 515 - // resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 516 - // Collection: tangled.KnotMemberNSID, 517 - // Repo: currentUser.Did, 518 - // Rkey: appview.TID(), 519 - // Record: &lexutil.LexiconTypeDecoder{ 520 - // Val: &tangled.KnotMember{ 521 - // Subject: subjectIdentity.DID.String(), 522 - // Domain: domain, 523 - // CreatedAt: createdAt, 524 - // }}, 525 - // }) 526 - // 527 - // // invalid record 528 - // if err != nil { 529 - // log.Printf("failed to create record: %s", err) 530 - // return 531 - // } 532 - // log.Println("created atproto record: ", resp.Uri) 533 - // 534 - // secret, err := db.GetRegistrationKey(s.db, domain) 535 - // if err != nil { 536 - // log.Printf("no key found for domain %s: %s\n", domain, err) 537 - // return 538 - // } 539 - // 540 - // ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 541 - // if err != nil { 542 - // log.Println("failed to create client to ", domain) 543 - // return 544 - // } 545 - // 546 - // ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 547 - // if err != nil { 548 - // log.Printf("failed to make request to %s: %s", domain, err) 549 - // return 550 - // } 551 - // 552 - // if ksResp.StatusCode != http.StatusNoContent { 553 - // w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 554 - // return 555 - // } 556 - // 557 - // err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 558 - // if err != nil { 559 - // w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 560 - // return 561 - // } 562 - // 563 - // w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 564 - // } 565 - 566 - // func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 567 - // } 568 - 569 249 func validateRepoName(name string) error { 570 250 // check for path traversal attempts 571 251 if name == "." || name == ".." || ··· 664 344 return 665 345 } 666 346 667 - rkey := appview.TID() 347 + rkey := tid.TID() 668 348 repo := &db.Repo{ 669 349 Did: user.Did, 670 350 Name: repoName, ··· 760 440 return 761 441 } 762 442 763 - if !s.config.Core.Dev { 764 - err = s.posthog.Enqueue(posthog.Capture{ 765 - DistinctId: user.Did, 766 - Event: "new_repo", 767 - Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri}, 768 - }) 769 - if err != nil { 770 - log.Println("failed to enqueue posthog event:", err) 771 - } 772 - } 443 + s.notifier.NewRepo(r.Context(), repo) 773 444 774 445 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 775 446 return
+6
appview/state/userutil/userutil.go
··· 51 51 func IsDid(s string) bool { 52 52 return didRegex.MatchString(s) 53 53 } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 + }
-11
appview/tid.go
··· 1 - package appview 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/syntax" 5 - ) 6 - 7 - var c syntax.TIDClock = syntax.NewTIDClock(0) 8 - 9 - func TID() string { 10 - return c.Next().String() 11 - }
+15
appview/xrpcclient/xrpc.go
··· 87 87 88 88 return &out, nil 89 89 } 90 + 91 + func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 92 + var out atproto.ServerGetServiceAuth_Output 93 + 94 + params := map[string]interface{}{ 95 + "aud": aud, 96 + "exp": exp, 97 + "lxm": lxm, 98 + } 99 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 100 + return nil, err 101 + } 102 + 103 + return &out, nil 104 + }
+33 -4
avatar/src/index.js
··· 1 1 export default { 2 2 async fetch(request, env) { 3 + // Helper function to generate a color from a string 4 + const stringToColor = (str) => { 5 + let hash = 0; 6 + for (let i = 0; i < str.length; i++) { 7 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 8 + } 9 + let color = "#"; 10 + for (let i = 0; i < 3; i++) { 11 + const value = (hash >> (i * 8)) & 0xff; 12 + color += ("00" + value.toString(16)).substr(-2); 13 + } 14 + return color; 15 + }; 16 + 3 17 const url = new URL(request.url); 4 18 const { pathname, searchParams } = url; 5 19 ··· 60 74 const profile = await profileResponse.json(); 61 75 const avatar = profile.avatar; 62 76 63 - if (!avatar) { 64 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 77 + let avatarUrl = profile.avatar; 78 + 79 + if (!avatarUrl) { 80 + // Generate a random color based on the actor string 81 + const bgColor = stringToColor(actor); 82 + const size = resizeToTiny ? 32 : 128; 83 + const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`; 84 + const svgData = new TextEncoder().encode(svg); 85 + 86 + response = new Response(svgData, { 87 + headers: { 88 + "Content-Type": "image/svg+xml", 89 + "Cache-Control": "public, max-age=43200", 90 + }, 91 + }); 92 + await cache.put(cacheKey, response.clone()); 93 + return response; 65 94 } 66 95 67 96 // Resize if requested 68 97 let avatarResponse; 69 98 if (resizeToTiny) { 70 - avatarResponse = await fetch(avatar, { 99 + avatarResponse = await fetch(avatarUrl, { 71 100 cf: { 72 101 image: { 73 102 width: 32, ··· 78 107 }, 79 108 }); 80 109 } else { 81 - avatarResponse = await fetch(avatar); 110 + avatarResponse = await fetch(avatarUrl); 82 111 } 83 112 84 113 if (!avatarResponse.ok) {
+1
cmd/gen.go
··· 40 40 tangled.PublicKey{}, 41 41 tangled.Repo{}, 42 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 43 44 tangled.RepoIssue{}, 44 45 tangled.RepoIssueComment{}, 45 46 tangled.RepoIssueState{},
+46 -3
docs/hacking.md
··· 32 32 nix run .#watch-tailwind 33 33 ``` 34 34 35 + To authenticate with the appview, you will need redis and 36 + OAUTH JWKs to be setup: 37 + 38 + ``` 39 + # oauth jwks should already be setup by the nix devshell: 40 + echo $TANGLED_OAUTH_JWKS 41 + {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 42 + 43 + # if not, you can set it up yourself: 44 + go build -o genjwks.out ./cmd/genjwks 45 + export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 46 + 47 + # run redis in at a new shell to store oauth sessions 48 + redis-server 49 + ``` 50 + 35 51 ## running a knot 36 52 37 53 An end-to-end knot setup requires setting up a machine with ··· 39 55 quite cumbersome. So the nix flake provides a 40 56 `nixosConfiguration` to do so. 41 57 42 - To begin, head to `http://localhost:3000` in the browser and 43 - generate a knot secret. Replace the existing secret in 44 - `flake.nix` with the newly generated secret. 58 + To begin, head to `http://localhost:3000/knots` in the browser 59 + and generate a knot secret. Replace the existing secret in 60 + `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 + secret. 45 62 46 63 You can now start a lightweight NixOS VM using 47 64 `nixos-shell` like so: ··· 71 88 git remote add local-dev git@nixos-shell:user/repo 72 89 git push local-dev main 73 90 ``` 91 + 92 + ## running a spindle 93 + 94 + Be sure to change the `owner` field for the spindle in 95 + `nix/vm.nix` to your own DID. The above VM should already 96 + be running a spindle on `localhost:6555`. You can head to 97 + the spindle dashboard on `http://localhost:3000/spindles`, 98 + and register a spindle with hostname `localhost:6555`. It 99 + should instantly be verified. You can then configure each 100 + repository to use this spindle and run CI jobs. 101 + 102 + Of interest when debugging spindles: 103 + 104 + ``` 105 + # service logs from journald: 106 + journalctl -xeu spindle 107 + 108 + # CI job logs from disk: 109 + ls /var/log/spindle 110 + 111 + # debugging spindle db: 112 + sqlite3 /var/lib/spindle/spindle.db 113 + 114 + # litecli has a nicer REPL interface: 115 + litecli /var/lib/spindle/spindle.db 116 + ```
+12
docs/knot-hosting.md
··· 191 191 ``` 192 192 193 193 Make sure to restart your SSH server! 194 + 195 + #### MOTD (message of the day) 196 + 197 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 198 + `/home/git/motd` file: 199 + 200 + ``` 201 + printf "Hi from this knot!\n" > /home/git/motd 202 + ``` 203 + 204 + Note that you should add a newline at the end if setting a non-empty message 205 + since the knot won't do this for you.
+4 -3
docs/spindle/architecture.md
··· 13 13 14 14 ### the engine 15 15 16 - At present, the only supported backend is Docker. Spindle executes each step in 17 - the pipeline in a fresh container, with state persisted across steps within the 18 - `/tangled/workspace` directory. 16 + At present, the only supported backend is Docker (and Podman, if Docker 17 + compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 + executes each step in the pipeline in a fresh container, with state persisted 19 + across steps within the `/tangled/workspace` directory. 19 20 20 21 The base image for the container is constructed on the fly using 21 22 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
+285
docs/spindle/openbao.md
··· 1 + # spindle secrets with openbao 2 + 3 + This document covers setting up Spindle to use OpenBao for secrets 4 + management via OpenBao Proxy instead of the default SQLite backend. 5 + 6 + ## overview 7 + 8 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 + authentication automatically using AppRole credentials, while Spindle 10 + connects to the local proxy instead of directly to the OpenBao server. 11 + 12 + This approach provides better security, automatic token renewal, and 13 + simplified application code. 14 + 15 + ## installation 16 + 17 + Install OpenBao from nixpkgs: 18 + 19 + ```bash 20 + nix shell nixpkgs#openbao # for a local server 21 + ``` 22 + 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 28 + 29 + Start OpenBao in dev mode: 30 + 31 + ```bash 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 + ``` 34 + 35 + This starts OpenBao on `http://localhost:8201` with a root token. 36 + 37 + Set up environment for bao CLI: 38 + 39 + ```bash 40 + export BAO_ADDR=http://localhost:8200 41 + export BAO_TOKEN=root 42 + ``` 43 + 44 + ### production 45 + 46 + You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 + achieved using Nix. 49 + 50 + Then, initialize the bao server: 51 + ```bash 52 + bao operator init -key-shares=1 -key-threshold=1 53 + ``` 54 + 55 + This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 + ```bash 57 + bao operator unseal <unseal_key> 58 + ``` 59 + 60 + All steps below remain the same across both dev and production setups. 61 + 62 + ### configure openbao server 63 + 64 + Create the spindle KV mount: 65 + 66 + ```bash 67 + bao secrets enable -path=spindle -version=2 kv 68 + ``` 69 + 70 + Set up AppRole authentication and policy: 71 + 72 + Create a policy file `spindle-policy.hcl`: 73 + 74 + ```hcl 75 + # Full access to spindle KV v2 data 76 + path "spindle/data/*" { 77 + capabilities = ["create", "read", "update", "delete"] 78 + } 79 + 80 + # Access to metadata for listing and management 81 + path "spindle/metadata/*" { 82 + capabilities = ["list", "read", "delete", "update"] 83 + } 84 + 85 + # Allow listing at root level 86 + path "spindle/" { 87 + capabilities = ["list"] 88 + } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 94 + ``` 95 + 96 + Apply the policy and create an AppRole: 97 + 98 + ```bash 99 + bao policy write spindle-policy spindle-policy.hcl 100 + bao auth enable approle 101 + bao write auth/approle/role/spindle \ 102 + token_policies="spindle-policy" \ 103 + token_ttl=1h \ 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 108 + ``` 109 + 110 + Get the credentials: 111 + 112 + ```bash 113 + # Get role ID (static) 114 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 + 116 + # Generate secret ID 117 + SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id) 118 + 119 + echo "Role ID: $ROLE_ID" 120 + echo "Secret ID: $SECRET_ID" 121 + ``` 122 + 123 + ### create proxy configuration 124 + 125 + Create the credential files: 126 + 127 + ```bash 128 + # Create directory for OpenBao files 129 + mkdir -p /tmp/openbao 130 + 131 + # Save credentials 132 + echo "$ROLE_ID" > /tmp/openbao/role-id 133 + echo "$SECRET_ID" > /tmp/openbao/secret-id 134 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 + ``` 136 + 137 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 + 139 + ```hcl 140 + # OpenBao server connection 141 + vault { 142 + address = "http://localhost:8200" 143 + } 144 + 145 + # Auto-Auth using AppRole 146 + auto_auth { 147 + method "approle" { 148 + mount_path = "auth/approle" 149 + config = { 150 + role_id_file_path = "/tmp/openbao/role-id" 151 + secret_id_file_path = "/tmp/openbao/secret-id" 152 + } 153 + } 154 + 155 + # Optional: write token to file for debugging 156 + sink "file" { 157 + config = { 158 + path = "/tmp/openbao/token" 159 + mode = 0640 160 + } 161 + } 162 + } 163 + 164 + # Proxy listener for Spindle 165 + listener "tcp" { 166 + address = "127.0.0.1:8201" 167 + tls_disable = true 168 + } 169 + 170 + # Enable API proxy with auto-auth token 171 + api_proxy { 172 + use_auto_auth_token = true 173 + } 174 + 175 + # Enable response caching 176 + cache { 177 + use_auto_auth_token = true 178 + } 179 + 180 + # Logging 181 + log_level = "info" 182 + ``` 183 + 184 + ### start the proxy 185 + 186 + Start OpenBao Proxy: 187 + 188 + ```bash 189 + bao proxy -config=/tmp/openbao/proxy.hcl 190 + ``` 191 + 192 + The proxy will authenticate with OpenBao and start listening on 193 + `127.0.0.1:8201`. 194 + 195 + ### configure spindle 196 + 197 + Set these environment variables for Spindle: 198 + 199 + ```bash 200 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 + ``` 204 + 205 + Start Spindle: 206 + 207 + Spindle will now connect to the local proxy, which handles all 208 + authentication automatically. 209 + 210 + ## production setup for proxy 211 + 212 + For production, you'll want to run the proxy as a service: 213 + 214 + Place your production configuration in `/etc/openbao/proxy.hcl` with 215 + proper TLS settings for the vault connection. 216 + 217 + ## verifying setup 218 + 219 + Test the proxy directly: 220 + 221 + ```bash 222 + # Check proxy health 223 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 + 225 + # Test token lookup through proxy 226 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 + ``` 228 + 229 + Test OpenBao operations through the server: 230 + 231 + ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 236 + bao kv list spindle/repos/ 237 + 238 + # Get a specific secret 239 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 + ``` 241 + 242 + ## how it works 243 + 244 + - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 + - The proxy authenticates with OpenBao using AppRole credentials 246 + - All Spindle requests go through the proxy, which injects authentication tokens 247 + - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 + - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 + - The proxy handles all token renewal automatically 250 + - Spindle no longer manages tokens or authentication directly 251 + 252 + ## troubleshooting 253 + 254 + **Connection refused**: Check that the OpenBao Proxy is running and 255 + listening on the configured address. 256 + 257 + **403 errors**: Verify the AppRole credentials are correct and the policy 258 + has the necessary permissions. 259 + 260 + **404 route errors**: The spindle KV mount probably doesn't exist - run 261 + the mount creation step again. 262 + 263 + **Proxy authentication failures**: Check the proxy logs and verify the 264 + role-id and secret-id files are readable and contain valid credentials. 265 + 266 + **Secret not found after writing**: This can indicate policy permission 267 + issues. Verify the policy includes both `spindle/data/*` and 268 + `spindle/metadata/*` paths with appropriate capabilities. 269 + 270 + Check proxy logs: 271 + 272 + ```bash 273 + # If running as systemd service 274 + journalctl -u openbao-proxy -f 275 + 276 + # If running directly, check the console output 277 + ``` 278 + 279 + Test AppRole authentication manually: 280 + 281 + ```bash 282 + bao write auth/approle/login \ 283 + role_id="$(cat /tmp/openbao/role-id)" \ 284 + secret_id="$(cat /tmp/openbao/secret-id)" 285 + ```
+7
docs/spindle/pipeline.md
··· 57 57 depth: 50 58 58 submodules: true 59 59 ``` 60 + 61 + ## git push options 62 + 63 + These are push options that can be used with the `--push-option (-o)` flag of git push: 64 + 65 + - `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push. 66 + - `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+27 -3
flake.nix
··· 190 190 }; 191 191 }); 192 192 193 - nixosModules.appview = import ./nix/modules/appview.nix {inherit self;}; 194 - nixosModules.knot = import ./nix/modules/knot.nix {inherit self;}; 195 - nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;}; 193 + nixosModules.appview = { 194 + lib, 195 + pkgs, 196 + ... 197 + }: { 198 + imports = [./nix/modules/appview.nix]; 199 + 200 + services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 201 + }; 202 + nixosModules.knot = { 203 + lib, 204 + pkgs, 205 + ... 206 + }: { 207 + imports = [./nix/modules/knot.nix]; 208 + 209 + services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 210 + }; 211 + nixosModules.spindle = { 212 + lib, 213 + pkgs, 214 + ... 215 + }: { 216 + imports = [./nix/modules/spindle.nix]; 217 + 218 + services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 + }; 196 220 nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 197 221 }; 198 222 }
+54 -34
go.mod
··· 1 1 module tangled.sh/tangled.sh/core 2 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 3 + go 1.24.4 6 4 7 5 require ( 8 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 9 8 github.com/alecthomas/chroma/v2 v2.15.0 9 + github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 15 16 github.com/cyphar/filepath-securejoin v0.4.1 16 17 github.com/dgraph-io/ristretto v0.2.0 17 18 github.com/docker/docker v28.2.2+incompatible ··· 22 23 github.com/go-git/go-git/v5 v5.14.0 23 24 github.com/google/uuid v1.6.0 24 25 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 26 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 27 github.com/hiddeco/sshsig v0.2.0 27 28 github.com/hpcloud/tail v1.0.0 28 29 github.com/ipfs/go-cid v0.5.0 29 30 github.com/lestrrat-go/jwx/v2 v2.1.6 30 31 github.com/mattn/go-sqlite3 v1.14.24 31 32 github.com/microcosm-cc/bluemonday v1.0.27 33 + github.com/openbao/openbao/api/v2 v2.3.0 32 34 github.com/posthog/posthog-go v1.5.5 33 - github.com/redis/go-redis/v9 v9.3.0 35 + github.com/redis/go-redis/v9 v9.7.3 34 36 github.com/resend/resend-go/v2 v2.15.0 35 37 github.com/sethvargo/go-envconfig v1.1.0 36 38 github.com/stretchr/testify v1.10.0 37 39 github.com/urfave/cli/v3 v3.3.3 38 40 github.com/whyrusleeping/cbor-gen v0.3.1 39 41 github.com/yuin/goldmark v1.4.13 40 - golang.org/x/crypto v0.38.0 41 - golang.org/x/net v0.40.0 42 + golang.org/x/crypto v0.40.0 43 + golang.org/x/net v0.42.0 44 + golang.org/x/sync v0.16.0 42 45 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 43 46 gopkg.in/yaml.v3 v3.0.1 44 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 47 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 45 48 ) 46 49 47 50 require ( 48 51 dario.cat/mergo v1.0.1 // indirect 49 52 github.com/Microsoft/go-winio v0.6.2 // indirect 50 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 53 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 54 + github.com/alecthomas/repr v0.4.0 // indirect 51 55 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 52 - github.com/avast/retry-go/v4 v4.6.1 // indirect 53 56 github.com/aymerick/douceur v0.2.0 // indirect 54 57 github.com/beorn7/perks v1.0.1 // indirect 55 58 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 59 github.com/casbin/govaluate v1.3.0 // indirect 60 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 57 61 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 62 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 59 63 github.com/containerd/errdefs v1.0.0 // indirect 60 64 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 65 github.com/containerd/log v0.1.0 // indirect ··· 68 72 github.com/docker/go-units v0.5.0 // indirect 69 73 github.com/emirpasic/gods v1.18.1 // indirect 70 74 github.com/felixge/httpsnoop v1.0.4 // indirect 75 + github.com/fsnotify/fsnotify v1.6.0 // indirect 71 76 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 77 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 78 github.com/go-git/go-billy/v5 v5.6.2 // indirect 74 - github.com/go-logr/logr v1.4.2 // indirect 79 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 80 + github.com/go-logr/logr v1.4.3 // indirect 75 81 github.com/go-logr/stdr v1.2.2 // indirect 76 82 github.com/go-redis/cache/v9 v9.0.0 // indirect 83 + github.com/go-test/deep v1.1.1 // indirect 77 84 github.com/goccy/go-json v0.10.5 // indirect 78 85 github.com/gogo/protobuf v1.3.2 // indirect 79 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 86 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 80 87 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 88 + github.com/golang/mock v1.6.0 // indirect 89 + github.com/google/go-querystring v1.1.0 // indirect 81 90 github.com/gorilla/css v1.0.1 // indirect 82 91 github.com/gorilla/securecookie v1.1.2 // indirect 92 + github.com/hashicorp/errwrap v1.1.0 // indirect 83 93 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 94 + github.com/hashicorp/go-multierror v1.1.1 // indirect 95 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 96 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 97 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 98 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 85 99 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 100 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 101 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 102 + github.com/hexops/gotextdiff v1.0.3 // indirect 87 103 github.com/ipfs/bbloom v0.0.4 // indirect 88 - github.com/ipfs/boxo v0.30.0 // indirect 89 - github.com/ipfs/go-block-format v0.2.1 // indirect 104 + github.com/ipfs/boxo v0.33.0 // indirect 105 + github.com/ipfs/go-block-format v0.2.2 // indirect 90 106 github.com/ipfs/go-datastore v0.8.2 // indirect 91 107 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 92 108 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 93 - github.com/ipfs/go-ipld-cbor v0.2.0 // indirect 94 - github.com/ipfs/go-ipld-format v0.6.1 // indirect 109 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 110 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 95 111 github.com/ipfs/go-log v1.0.5 // indirect 96 112 github.com/ipfs/go-log/v2 v2.6.0 // indirect 97 113 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 98 114 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 115 github.com/klauspost/compress v1.18.0 // indirect 100 - github.com/klauspost/cpuid/v2 v2.2.10 // indirect 101 - github.com/lestrrat-go/blackmagic v1.0.3 // indirect 116 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 117 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 102 118 github.com/lestrrat-go/httpcc v1.0.1 // indirect 103 119 github.com/lestrrat-go/httprc v1.0.6 // indirect 104 120 github.com/lestrrat-go/iter v1.0.2 // indirect 105 121 github.com/lestrrat-go/option v1.0.1 // indirect 106 122 github.com/mattn/go-isatty v0.0.20 // indirect 107 123 github.com/minio/sha256-simd v1.0.1 // indirect 124 + github.com/mitchellh/mapstructure v1.5.0 // indirect 108 125 github.com/moby/docker-image-spec v1.3.1 // indirect 109 126 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 127 github.com/moby/term v0.5.2 // indirect ··· 116 133 github.com/multiformats/go-multihash v0.2.3 // indirect 117 134 github.com/multiformats/go-varint v0.0.7 // indirect 118 135 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 136 + github.com/onsi/gomega v1.37.0 // indirect 119 137 github.com/opencontainers/go-digest v1.0.0 // indirect 120 138 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 139 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 122 140 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 141 github.com/pkg/errors v0.9.1 // indirect 124 142 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 125 143 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 126 144 github.com/prometheus/client_golang v1.22.0 // indirect 127 145 github.com/prometheus/client_model v0.6.2 // indirect 128 - github.com/prometheus/common v0.63.0 // indirect 146 + github.com/prometheus/common v0.64.0 // indirect 129 147 github.com/prometheus/procfs v0.16.1 // indirect 148 + github.com/ryanuber/go-glob v1.0.0 // indirect 130 149 github.com/segmentio/asm v1.2.0 // indirect 131 150 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 151 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 136 155 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 137 156 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 138 157 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 139 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 140 - go.opentelemetry.io/otel v1.36.0 // indirect 141 - go.opentelemetry.io/otel/metric v1.36.0 // indirect 142 - go.opentelemetry.io/otel/trace v1.36.0 // indirect 158 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 159 + go.opentelemetry.io/otel v1.37.0 // indirect 160 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 161 + go.opentelemetry.io/otel/metric v1.37.0 // indirect 162 + go.opentelemetry.io/otel/trace v1.37.0 // indirect 143 163 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 144 164 go.uber.org/atomic v1.11.0 // indirect 145 165 go.uber.org/multierr v1.11.0 // indirect 146 166 go.uber.org/zap v1.27.0 // indirect 147 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 148 - golang.org/x/sync v0.14.0 // indirect 149 - golang.org/x/sys v0.33.0 // indirect 150 - golang.org/x/time v0.8.0 // indirect 151 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 152 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 153 - google.golang.org/grpc v1.72.1 // indirect 167 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 168 + golang.org/x/sys v0.34.0 // indirect 169 + golang.org/x/text v0.27.0 // indirect 170 + golang.org/x/time v0.12.0 // indirect 171 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 172 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 173 + google.golang.org/grpc v1.73.0 // indirect 154 174 google.golang.org/protobuf v1.36.6 // indirect 155 175 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 176 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129 -87
go.sum
··· 7 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 - github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 11 - github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 10 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 27 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 26 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 - github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 55 - github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 54 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 56 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 91 93 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 92 94 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 93 95 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 94 - github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 95 - github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 96 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 97 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 96 98 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 97 99 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 98 100 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 99 - github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 100 101 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 102 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 103 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 101 104 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 102 105 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 103 106 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 114 117 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 115 118 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 116 119 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 120 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 121 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 117 122 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 118 123 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 119 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 120 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 124 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 125 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 121 126 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 122 127 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 123 128 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 124 129 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 125 130 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 131 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 132 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 126 133 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 127 134 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 128 135 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 129 136 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 130 137 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 131 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 132 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 138 + github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 139 + github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 133 140 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 134 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 135 - github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 136 142 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 143 + github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 144 + github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 137 145 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 138 146 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 139 147 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 146 154 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 155 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 156 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 157 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 158 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 159 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 160 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 161 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 162 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 163 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 164 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 154 165 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 166 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 167 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 166 177 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 167 178 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 168 179 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 169 - github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 170 - github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 180 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 181 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 171 182 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 172 183 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 184 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 185 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 186 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 173 187 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 174 188 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 175 189 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 176 190 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 177 - github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 178 - github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 191 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 192 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 193 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 194 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 195 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 196 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 197 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 198 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 199 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 200 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 179 201 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 180 202 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 181 203 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 182 204 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 205 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 206 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 183 207 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 184 208 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 185 209 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 189 213 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 190 214 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 191 215 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 192 - github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ= 193 - github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370= 194 - github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q= 195 - github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk= 216 + github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 217 + github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 218 + github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 219 + github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 196 220 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 197 221 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 198 222 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 205 229 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 206 230 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 207 231 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 208 - github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0= 209 - github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0= 210 - github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ= 211 - github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs= 232 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 233 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 234 + github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 235 + github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 212 236 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 213 237 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 214 238 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 216 240 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 217 241 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 218 242 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 219 - github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE= 220 - github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M= 221 243 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 222 244 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 223 245 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 229 251 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 230 252 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 231 253 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 232 - github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 233 - github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 254 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 255 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 234 256 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 235 257 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 236 258 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 239 261 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 240 262 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 241 263 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 242 - github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 243 - github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 264 + github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 265 + github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 244 266 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 245 267 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 246 268 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 251 273 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 252 274 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 253 275 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 254 - github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 255 - github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 256 - github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= 257 - github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= 258 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 259 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 276 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 277 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 260 278 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 261 279 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 262 280 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 265 283 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 284 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 267 285 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 286 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 287 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 268 288 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 269 289 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 270 290 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 281 301 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 282 302 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 283 303 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 284 - github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo= 285 - github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= 286 304 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 287 305 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 288 - github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= 289 - github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= 290 306 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 307 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 308 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 318 334 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 335 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 336 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 321 - github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 322 - github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 337 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 338 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 339 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 340 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 323 341 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 324 342 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 325 343 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 326 344 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 327 - github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 328 345 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 346 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 347 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 329 348 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 330 349 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 331 350 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 346 365 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 347 366 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 348 367 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 349 - github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 350 - github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 368 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 369 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 351 370 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 352 371 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 353 372 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 354 - github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 355 - github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 373 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 374 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 356 375 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 357 376 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 358 377 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 360 379 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 361 380 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 362 381 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 382 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 383 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 363 384 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 364 385 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 365 386 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 404 425 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 405 426 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 406 427 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 428 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 407 429 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 408 430 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 409 431 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 413 435 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 414 436 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 415 437 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 416 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 417 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 418 - go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 419 - go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 420 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= 421 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= 438 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= 439 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= 440 + go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 441 + go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 442 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 443 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 422 444 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 423 445 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= 424 - go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 425 - go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 426 - go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 427 - go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 428 - go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= 429 - go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= 430 - go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 431 - go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 446 + go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 447 + go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 448 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 449 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 450 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 451 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 452 + go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 453 + go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 432 454 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 433 455 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 434 456 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 451 473 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 452 474 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 453 475 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 454 - golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 455 - golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 456 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 457 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 476 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 477 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 478 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 479 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 480 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 458 481 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 459 482 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 460 483 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 461 484 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 485 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 462 486 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 463 487 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 464 488 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 465 489 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 490 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 466 491 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 467 492 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 468 493 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 471 496 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 472 497 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 473 498 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 499 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 474 500 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 475 501 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 476 502 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 480 506 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 481 507 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 482 508 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 483 - golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 484 - golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 509 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 510 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 511 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 512 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 485 513 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 486 514 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 515 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 489 517 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 518 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 519 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 492 - golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 493 - golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 520 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 521 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 494 522 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 495 523 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 496 524 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 502 530 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 531 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 532 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 533 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 505 534 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 535 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 506 536 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 537 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 538 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 510 540 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 541 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 542 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 543 + golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 544 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 545 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 546 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 547 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 548 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 517 549 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 518 - golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 519 - golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 550 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 551 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 552 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 553 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 520 554 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 521 555 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 522 556 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 523 557 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 524 558 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 525 559 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 526 - golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 527 - golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 560 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 561 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 562 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 563 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 564 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 528 565 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 529 566 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 530 567 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 532 569 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 533 570 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 534 571 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 535 - golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 536 - golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 537 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 538 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 572 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 573 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 574 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 575 + golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 576 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 577 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 578 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 539 579 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 580 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 581 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 547 587 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 548 588 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 549 589 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 590 + golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 550 591 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 551 592 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 552 593 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 553 594 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 595 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 554 596 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 597 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 556 598 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 557 599 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 558 600 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 559 601 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 560 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= 561 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= 562 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= 563 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 564 - google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 565 - google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 602 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= 603 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= 604 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= 605 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 606 + google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 607 + google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 566 608 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 567 609 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 568 610 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 599 641 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 600 642 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 601 643 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 602 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90= 603 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ= 644 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 645 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 604 646 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 605 647 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20 -4
guard/guard.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 6 8 "log/slog" 7 9 "net/http" 8 10 "net/url" ··· 13 15 "github.com/bluesky-social/indigo/atproto/identity" 14 16 securejoin "github.com/cyphar/filepath-securejoin" 15 17 "github.com/urfave/cli/v3" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 + "tangled.sh/tangled.sh/core/idresolver" 17 19 "tangled.sh/tangled.sh/core/log" 18 20 ) 19 21 ··· 43 45 Usage: "internal API endpoint", 44 46 Value: "http://localhost:5444", 45 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 46 53 }, 47 54 } 48 55 } ··· 54 61 gitDir := cmd.String("git-dir") 55 62 logPath := cmd.String("log-path") 56 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 57 65 58 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 67 if err != nil { ··· 149 157 "fullPath", fullPath, 150 158 "client", clientIP) 151 159 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 154 166 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 156 171 } 172 + io.Copy(os.Stderr, motdReader) 157 173 158 174 gitCmd := exec.Command(gitCommand, fullPath) 159 175 gitCmd.Stdout = os.Stdout
+24
hook/hook.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 "net/http" 8 9 "os" ··· 10 11 11 12 "github.com/urfave/cli/v3" 12 13 ) 14 + 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 13 18 14 19 // The hook command is nested like so: 15 20 // ··· 36 41 Usage: "endpoint for the internal API", 37 42 Value: "http://localhost:5444", 38 43 }, 44 + &cli.StringSliceFlag{ 45 + Name: "push-option", 46 + Usage: "any push option from git", 47 + }, 39 48 }, 40 49 Commands: []*cli.Command{ 41 50 { ··· 52 61 userDid := cmd.String("user-did") 53 62 userHandle := cmd.String("user-handle") 54 63 endpoint := cmd.String("internal-api") 64 + pushOptions := cmd.StringSlice("push-option") 55 65 56 66 payloadReader := bufio.NewReader(os.Stdin) 57 67 payload, _ := payloadReader.ReadString('\n') ··· 67 77 req.Header.Set("X-Git-Dir", gitDir) 68 78 req.Header.Set("X-Git-User-Did", userDid) 69 79 req.Header.Set("X-Git-User-Handle", userHandle) 80 + if pushOptions != nil { 81 + for _, option := range pushOptions { 82 + req.Header.Add("X-Git-Push-Option", option) 83 + } 84 + } 70 85 71 86 resp, err := client.Do(req) 72 87 if err != nil { ··· 76 91 77 92 if resp.StatusCode != http.StatusOK { 78 93 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 94 + } 95 + 96 + var data HookResponse 97 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 98 + return fmt.Errorf("failed to decode response: %w", err) 99 + } 100 + 101 + for _, message := range data.Messages { 102 + fmt.Println(message) 79 103 } 80 104 81 105 return nil
+6 -1
hook/setup.go
··· 133 133 134 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 136 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve 136 + push_options=() 137 + for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do 138 + option_var="GIT_PUSH_OPTION_$i" 139 + push_options+=(-push-option "${!option_var}") 140 + done 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 137 142 `, executablePath, config.internalApi) 138 143 139 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+116
idresolver/resolver.go
··· 1 + package idresolver 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/carlmjohnson/versioninfo" 14 + ) 15 + 16 + type Resolver struct { 17 + directory identity.Directory 18 + } 19 + 20 + func BaseDirectory() identity.Directory { 21 + base := identity.BaseDirectory{ 22 + PLCURL: identity.DefaultPLCURL, 23 + HTTPClient: http.Client{ 24 + Timeout: time.Second * 10, 25 + Transport: &http.Transport{ 26 + // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 27 + IdleConnTimeout: time.Millisecond * 1000, 28 + MaxIdleConns: 100, 29 + }, 30 + }, 31 + Resolver: net.Resolver{ 32 + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 33 + d := net.Dialer{Timeout: time.Second * 3} 34 + return d.DialContext(ctx, network, address) 35 + }, 36 + }, 37 + TryAuthoritativeDNS: true, 38 + // primary Bluesky PDS instance only supports HTTP resolution method 39 + SkipDNSDomainSuffixes: []string{".bsky.social"}, 40 + UserAgent: "indigo-identity/" + versioninfo.Short(), 41 + } 42 + return &base 43 + } 44 + 45 + func RedisDirectory(url string) (identity.Directory, error) { 46 + hitTTL := time.Hour * 24 47 + errTTL := time.Second * 30 48 + invalidHandleTTL := time.Minute * 5 49 + return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 50 + } 51 + 52 + func DefaultResolver() *Resolver { 53 + return &Resolver{ 54 + directory: identity.DefaultDirectory(), 55 + } 56 + } 57 + 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return &Resolver{ 64 + directory: directory, 65 + }, nil 66 + } 67 + 68 + func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 69 + id, err := syntax.ParseAtIdentifier(arg) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + return r.directory.Lookup(ctx, *id) 75 + } 76 + 77 + func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 78 + results := make([]*identity.Identity, len(idents)) 79 + var wg sync.WaitGroup 80 + 81 + done := make(chan struct{}) 82 + defer close(done) 83 + 84 + for idx, ident := range idents { 85 + wg.Add(1) 86 + go func(index int, id string) { 87 + defer wg.Done() 88 + 89 + select { 90 + case <-ctx.Done(): 91 + results[index] = nil 92 + case <-done: 93 + results[index] = nil 94 + default: 95 + identity, _ := r.ResolveIdent(ctx, id) 96 + results[index] = identity 97 + } 98 + }(idx, ident) 99 + } 100 + 101 + wg.Wait() 102 + return results 103 + } 104 + 105 + func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 106 + id, err := syntax.ParseAtIdentifier(arg) 107 + if err != nil { 108 + return err 109 + } 110 + 111 + return r.directory.Purge(ctx, *id) 112 + } 113 + 114 + func (r *Resolver) Directory() identity.Directory { 115 + return r.directory 116 + }
+1 -2
input.css
··· 100 100 101 101 .prose img { 102 102 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 103 + margin: 0; 105 104 vertical-align: middle; 106 105 } 107 106 }
+13
jetstream/jetstream.go
··· 52 52 j.mu.Unlock() 53 53 } 54 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 55 68 type processor func(context.Context, *models.Event) error 56 69 57 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 ··· 23 25 24 26 // This disables signature verification so use with caution. 25 27 Dev bool `env:"DEV, default=false"` 28 + } 29 + 30 + func (s Server) Did() syntax.DID { 31 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 26 32 } 27 33 28 34 type Config struct {
+37 -18
knotserver/handler.go
··· 8 8 "runtime/debug" 9 9 10 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/idresolver" 11 12 "tangled.sh/tangled.sh/core/jetstream" 12 13 "tangled.sh/tangled.sh/core/knotserver/config" 13 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 14 17 "tangled.sh/tangled.sh/core/notifier" 15 18 "tangled.sh/tangled.sh/core/rbac" 16 - ) 17 - 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 19 ) 21 20 22 21 type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 29 30 30 // init is a channel that is closed when the knot has been initailized 31 31 // i.e. when the first user (knot owner) has been added. ··· 37 37 r := chi.NewRouter() 38 38 39 39 h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - init: make(chan struct{}), 40 + c: c, 41 + db: db, 42 + e: e, 43 + l: l, 44 + jc: jc, 45 + n: n, 46 + resolver: idresolver.DefaultResolver(), 47 + init: make(chan struct{}), 47 48 } 48 49 49 - err := e.AddKnot(ThisServer) 50 + err := e.AddKnot(rbac.ThisServer) 50 51 if err != nil { 51 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 52 53 } ··· 131 132 }) 132 133 }) 133 134 135 + // xrpc apis 136 + r.Mount("/xrpc", h.XrpcRouter()) 137 + 134 138 // Create a new repository. 135 139 r.Route("/repo", func(r chi.Router) { 136 140 r.Use(h.VerifySignature) ··· 161 165 r.Get("/keys", h.Keys) 162 166 163 167 return r, nil 168 + } 169 + 170 + func (h *Handle) XrpcRouter() http.Handler { 171 + logger := tlog.New("knots") 172 + 173 + xrpc := &xrpc.Xrpc{ 174 + Config: h.c, 175 + Db: h.db, 176 + Ingester: h.jc, 177 + Enforcer: h.e, 178 + Logger: logger, 179 + Notifier: h.n, 180 + Resolver: h.resolver, 181 + } 182 + return xrpc.Router() 164 183 } 165 184 166 185 // version is set during build time.
+65 -4
knotserver/ingester.go
··· 17 17 "github.com/bluesky-social/jetstream/pkg/models" 18 18 securejoin "github.com/cyphar/filepath-securejoin" 19 19 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 23 "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/rbac" 24 25 "tangled.sh/tangled.sh/core/workflow" 25 26 ) 26 27 ··· 46 47 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 47 48 } 48 49 49 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 50 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 50 51 if err != nil || !ok { 51 52 l.Error("failed to add member", "did", did) 52 53 return fmt.Errorf("failed to enforce permissions: %w", err) 53 54 } 54 55 55 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 56 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 56 57 l.Error("failed to add member", "error", err) 57 58 return fmt.Errorf("failed to add member: %w", err) 58 59 } ··· 212 213 return h.db.InsertEvent(event, h.n) 213 214 } 214 215 216 + // duplicated from add collaborator 217 + func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 218 + repoAt, err := syntax.ParseATURI(record.Repo) 219 + if err != nil { 220 + return err 221 + } 222 + 223 + resolver := idresolver.DefaultResolver() 224 + 225 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + if err != nil || subjectId.Handle.IsInvalidHandle() { 227 + return err 228 + } 229 + 230 + // TODO: fix this for good, we need to fetch the record here unfortunately 231 + // resolve this aturi to extract the repo record 232 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 233 + if err != nil || owner.Handle.IsInvalidHandle() { 234 + return fmt.Errorf("failed to resolve handle: %w", err) 235 + } 236 + 237 + xrpcc := xrpc.Client{ 238 + Host: owner.PDSEndpoint(), 239 + } 240 + 241 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 242 + if err != nil { 243 + return err 244 + } 245 + 246 + repo := resp.Value.Val.(*tangled.Repo) 247 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 + 249 + // check perms for this user 250 + if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 251 + return fmt.Errorf("insufficient permissions: %w", err) 252 + } 253 + 254 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 255 + return err 256 + } 257 + h.jc.AddDid(subjectId.DID.String()) 258 + 259 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 260 + return err 261 + } 262 + 263 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 264 + } 265 + 215 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 216 267 l := log.FromContext(ctx) 217 268 ··· 265 316 defer func() { 266 317 eventTime := event.TimeUS 267 318 lastTimeUs := eventTime + 1 268 - fmt.Println("lastTimeUs", lastTimeUs) 269 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 270 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 271 321 } ··· 291 341 if err := h.processKnotMember(ctx, did, record); err != nil { 292 342 return fmt.Errorf("failed to process knot member: %w", err) 293 343 } 344 + 294 345 case tangled.RepoPullNSID: 295 346 var record tangled.RepoPull 296 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 299 350 if err := h.processPull(ctx, did, record); err != nil { 300 351 return fmt.Errorf("failed to process knot member: %w", err) 301 352 } 353 + 354 + case tangled.RepoCollaboratorNSID: 355 + var record tangled.RepoCollaborator 356 + if err := json.Unmarshal(raw, &record); err != nil { 357 + return fmt.Errorf("failed to unmarshal record: %w", err) 358 + } 359 + if err := h.processCollaborator(ctx, did, record); err != nil { 360 + return fmt.Errorf("failed to process knot member: %w", err) 361 + } 362 + 302 363 } 303 364 304 365 return err
+62 -5
knotserver/internal.go
··· 13 13 "github.com/go-chi/chi/v5" 14 14 "github.com/go-chi/chi/v5/middleware" 15 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/hook" 16 17 "tangled.sh/tangled.sh/core/knotserver/config" 17 18 "tangled.sh/tangled.sh/core/knotserver/db" 18 19 "tangled.sh/tangled.sh/core/knotserver/git" ··· 38 39 return 39 40 } 40 41 41 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 42 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 42 43 if err != nil || !ok { 43 44 w.WriteHeader(http.StatusForbidden) 44 45 return ··· 64 65 return 65 66 } 66 67 68 + type PushOptions struct { 69 + skipCi bool 70 + verboseCi bool 71 + } 72 + 67 73 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 68 74 l := h.l.With("handler", "PostReceiveHook") 69 75 ··· 90 96 // non-fatal 91 97 } 92 98 99 + // extract any push options 100 + pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 101 + pushOptions := PushOptions{} 102 + for _, option := range pushOptionsRaw { 103 + if option == "skip-ci" || option == "ci-skip" { 104 + pushOptions.skipCi = true 105 + } 106 + if option == "verbose-ci" || option == "ci-verbose" { 107 + pushOptions.verboseCi = true 108 + } 109 + } 110 + 111 + resp := hook.HookResponse{ 112 + Messages: make([]string, 0), 113 + } 114 + 93 115 for _, line := range lines { 94 116 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 95 117 if err != nil { ··· 97 119 // non-fatal 98 120 } 99 121 100 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 122 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 101 123 if err != nil { 102 124 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 103 125 // non-fatal 104 126 } 105 127 } 128 + 129 + writeJSON(w, resp) 106 130 } 107 131 108 132 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 148 172 return h.db.InsertEvent(event, h.n) 149 173 } 150 174 151 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 175 + func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 176 + if pushOptions.skipCi { 177 + return nil 178 + } 179 + 152 180 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 153 181 if err != nil { 154 182 return err ··· 169 197 return err 170 198 } 171 199 200 + pipelineParseErrors := []string{} 201 + 172 202 var pipeline workflow.Pipeline 173 203 for _, e := range workflowDir { 174 204 if !e.IsFile { ··· 183 213 184 214 wf, err := workflow.FromFile(e.Name, contents) 185 215 if err != nil { 186 - // TODO: log here, respond to client that is pushing 187 216 h.l.Error("failed to parse workflow", "err", err, "path", fpath) 217 + pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 188 218 continue 189 219 } 190 220 ··· 209 239 }, 210 240 } 211 241 212 - // TODO: send the diagnostics back to the user here via stderr 213 242 cp := compiler.Compile(pipeline) 214 243 eventJson, err := json.Marshal(cp) 215 244 if err != nil { 216 245 return err 246 + } 247 + 248 + if pushOptions.verboseCi { 249 + hasDiagnostics := false 250 + if len(pipelineParseErrors) > 0 { 251 + hasDiagnostics = true 252 + *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 253 + for _, error := range pipelineParseErrors { 254 + *clientMsgs = append(*clientMsgs, error) 255 + } 256 + } 257 + if len(compiler.Diagnostics.Errors) > 0 { 258 + hasDiagnostics = true 259 + *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 260 + for _, error := range compiler.Diagnostics.Errors { 261 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 262 + } 263 + } 264 + if len(compiler.Diagnostics.Warnings) > 0 { 265 + hasDiagnostics = true 266 + *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 267 + for _, warning := range compiler.Diagnostics.Warnings { 268 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 269 + } 270 + } 271 + if !hasDiagnostics { 272 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 273 + } 217 274 } 218 275 219 276 // do not run empty pipelines
+17 -8
knotserver/routes.go
··· 29 29 "tangled.sh/tangled.sh/core/knotserver/db" 30 30 "tangled.sh/tangled.sh/core/knotserver/git" 31 31 "tangled.sh/tangled.sh/core/patchutil" 32 + "tangled.sh/tangled.sh/core/rbac" 32 33 "tangled.sh/tangled.sh/core/types" 33 34 ) 34 35 ··· 285 286 mimeType = "image/svg+xml" 286 287 } 287 288 288 - if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 289 - l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 290 - writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 289 + // allow image, video, and text/plain files to be served directly 290 + switch { 291 + case strings.HasPrefix(mimeType, "image/"): 292 + // allowed 293 + case strings.HasPrefix(mimeType, "video/"): 294 + // allowed 295 + case strings.HasPrefix(mimeType, "text/plain"): 296 + // allowed 297 + default: 298 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 291 300 return 292 301 } 293 302 ··· 674 683 } 675 684 676 685 // add perms for this user to access the repo 677 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 686 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 678 687 if err != nil { 679 688 l.Error("adding repo permissions", "error", err.Error()) 680 689 writeError(w, err.Error(), http.StatusInternalServerError) ··· 892 901 } 893 902 894 903 // add perms for this user to access the repo 895 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 904 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 896 905 if err != nil { 897 906 l.Error("adding repo permissions", "error", err.Error()) 898 907 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1146 1155 } 1147 1156 h.jc.AddDid(did) 1148 1157 1149 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1158 + if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1150 1159 l.Error("adding member", "error", err.Error()) 1151 1160 writeError(w, err.Error(), http.StatusInternalServerError) 1152 1161 return ··· 1184 1193 h.jc.AddDid(data.Did) 1185 1194 1186 1195 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1187 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1196 + if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1188 1197 l.Error("adding repo collaborator", "error", err.Error()) 1189 1198 writeError(w, err.Error(), http.StatusInternalServerError) 1190 1199 return ··· 1281 1290 } 1282 1291 h.jc.AddDid(data.Did) 1283 1292 1284 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1293 + if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1285 1294 l.Error("adding owner", "error", err.Error()) 1286 1295 writeError(w, err.Error(), http.StatusInternalServerError) 1287 1296 return
+1
knotserver/server.go
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
-5
knotserver/util.go
··· 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "github.com/go-chi/chi/v5" 11 - "github.com/microcosm-cc/bluemonday" 12 11 ) 13 - 14 - func sanitize(content []byte) []byte { 15 - return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 16 - } 17 12 18 13 func didPath(r *http.Request) string { 19 14 did := chi.URLParam(r, "did")
+149
knotserver/xrpc/router.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "strings" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/jetstream" 14 + "tangled.sh/tangled.sh/core/knotserver/config" 15 + "tangled.sh/tangled.sh/core/knotserver/db" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + 19 + "github.com/bluesky-social/indigo/atproto/auth" 20 + "github.com/go-chi/chi/v5" 21 + ) 22 + 23 + type Xrpc struct { 24 + Config *config.Config 25 + Db *db.DB 26 + Ingester *jetstream.JetstreamClient 27 + Enforcer *rbac.Enforcer 28 + Logger *slog.Logger 29 + Notifier *notifier.Notifier 30 + Resolver *idresolver.Resolver 31 + } 32 + 33 + func (x *Xrpc) Router() http.Handler { 34 + r := chi.NewRouter() 35 + 36 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 + 38 + return r 39 + } 40 + 41 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 + l := x.Logger.With("url", r.URL) 44 + 45 + token := r.Header.Get("Authorization") 46 + token = strings.TrimPrefix(token, "Bearer ") 47 + 48 + s := auth.ServiceAuthValidator{ 49 + Audience: x.Config.Server.Did().String(), 50 + Dir: x.Resolver.Directory(), 51 + } 52 + 53 + did, err := s.Validate(r.Context(), token, nil) 54 + if err != nil { 55 + l.Error("signature verification failed", "err", err) 56 + writeError(w, AuthError(err), http.StatusForbidden) 57 + return 58 + } 59 + 60 + r = r.WithContext( 61 + context.WithValue(r.Context(), ActorDid, did), 62 + ) 63 + 64 + next.ServeHTTP(w, r) 65 + }) 66 + } 67 + 68 + type XrpcError struct { 69 + Tag string `json:"error"` 70 + Message string `json:"message"` 71 + } 72 + 73 + func NewXrpcError(opts ...ErrOpt) XrpcError { 74 + x := XrpcError{} 75 + for _, o := range opts { 76 + o(&x) 77 + } 78 + 79 + return x 80 + } 81 + 82 + type ErrOpt = func(xerr *XrpcError) 83 + 84 + func WithTag(tag string) ErrOpt { 85 + return func(xerr *XrpcError) { 86 + xerr.Tag = tag 87 + } 88 + } 89 + 90 + func WithMessage[S ~string](s S) ErrOpt { 91 + return func(xerr *XrpcError) { 92 + xerr.Message = string(s) 93 + } 94 + } 95 + 96 + func WithError(e error) ErrOpt { 97 + return func(xerr *XrpcError) { 98 + xerr.Message = e.Error() 99 + } 100 + } 101 + 102 + var MissingActorDidError = NewXrpcError( 103 + WithTag("MissingActorDid"), 104 + WithMessage("actor DID not supplied"), 105 + ) 106 + 107 + var AuthError = func(err error) XrpcError { 108 + return NewXrpcError( 109 + WithTag("Auth"), 110 + WithError(fmt.Errorf("signature verification failed: %w", err)), 111 + ) 112 + } 113 + 114 + var InvalidRepoError = func(r string) XrpcError { 115 + return NewXrpcError( 116 + WithTag("InvalidRepo"), 117 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 + ) 119 + } 120 + 121 + var AccessControlError = func(d string) XrpcError { 122 + return NewXrpcError( 123 + WithTag("AccessControl"), 124 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 + ) 126 + } 127 + 128 + var GitError = func(e error) XrpcError { 129 + return NewXrpcError( 130 + WithTag("Git"), 131 + WithError(fmt.Errorf("git error: %w", e)), 132 + ) 133 + } 134 + 135 + func GenericError(err error) XrpcError { 136 + return NewXrpcError( 137 + WithTag("Generic"), 138 + WithError(err), 139 + ) 140 + } 141 + 142 + // this is slightly different from http_util::write_error to follow the spec: 143 + // 144 + // the json object returned must include an "error" and a "message" 145 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 + w.Header().Set("Content-Type", "application/json") 147 + w.WriteHeader(status) 148 + json.NewEncoder(w).Encode(e) 149 + }
+87
knotserver/xrpc/set_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + ) 16 + 17 + const ActorDid string = "ActorDid" 18 + 19 + func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoSetDefaultBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 + if err != nil { 62 + fail(GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(InvalidRepoError(data.Repo)) 76 + return 77 + } 78 + 79 + err = gr.SetDefaultBranch(data.DefaultBranch) 80 + if err != nil { 81 + l.Error("setting default branch", "error", err.Error()) 82 + writeError(w, GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
+263
lexicons/pipeline/pipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "dependencies", 153 + "steps", 154 + "environment", 155 + "clone" 156 + ], 157 + "properties": { 158 + "name": { 159 + "type": "string" 160 + }, 161 + "dependencies": { 162 + "type": "array", 163 + "items": { 164 + "type": "ref", 165 + "ref": "#dependency" 166 + } 167 + }, 168 + "steps": { 169 + "type": "array", 170 + "items": { 171 + "type": "ref", 172 + "ref": "#step" 173 + } 174 + }, 175 + "environment": { 176 + "type": "array", 177 + "items": { 178 + "type": "ref", 179 + "ref": "#pair" 180 + } 181 + }, 182 + "clone": { 183 + "type": "ref", 184 + "ref": "#cloneOpts" 185 + } 186 + } 187 + }, 188 + "dependency": { 189 + "type": "object", 190 + "required": [ 191 + "registry", 192 + "packages" 193 + ], 194 + "properties": { 195 + "registry": { 196 + "type": "string" 197 + }, 198 + "packages": { 199 + "type": "array", 200 + "items": { 201 + "type": "string" 202 + } 203 + } 204 + } 205 + }, 206 + "cloneOpts": { 207 + "type": "object", 208 + "required": [ 209 + "skip", 210 + "depth", 211 + "submodules" 212 + ], 213 + "properties": { 214 + "skip": { 215 + "type": "boolean" 216 + }, 217 + "depth": { 218 + "type": "integer" 219 + }, 220 + "submodules": { 221 + "type": "boolean" 222 + } 223 + } 224 + }, 225 + "step": { 226 + "type": "object", 227 + "required": [ 228 + "name", 229 + "command" 230 + ], 231 + "properties": { 232 + "name": { 233 + "type": "string" 234 + }, 235 + "command": { 236 + "type": "string" 237 + }, 238 + "environment": { 239 + "type": "array", 240 + "items": { 241 + "type": "ref", 242 + "ref": "#pair" 243 + } 244 + } 245 + } 246 + }, 247 + "pair": { 248 + "type": "object", 249 + "required": [ 250 + "key", 251 + "value" 252 + ], 253 + "properties": { 254 + "key": { 255 + "type": "string" 256 + }, 257 + "value": { 258 + "type": "string" 259 + } 260 + } 261 + } 262 + } 263 + }
-263
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "triggerMetadata", 14 - "workflows" 15 - ], 16 - "properties": { 17 - "triggerMetadata": { 18 - "type": "ref", 19 - "ref": "#triggerMetadata" 20 - }, 21 - "workflows": { 22 - "type": "array", 23 - "items": { 24 - "type": "ref", 25 - "ref": "#workflow" 26 - } 27 - } 28 - } 29 - } 30 - }, 31 - "triggerMetadata": { 32 - "type": "object", 33 - "required": [ 34 - "kind", 35 - "repo" 36 - ], 37 - "properties": { 38 - "kind": { 39 - "type": "string", 40 - "enum": [ 41 - "push", 42 - "pull_request", 43 - "manual" 44 - ] 45 - }, 46 - "repo": { 47 - "type": "ref", 48 - "ref": "#triggerRepo" 49 - }, 50 - "push": { 51 - "type": "ref", 52 - "ref": "#pushTriggerData" 53 - }, 54 - "pullRequest": { 55 - "type": "ref", 56 - "ref": "#pullRequestTriggerData" 57 - }, 58 - "manual": { 59 - "type": "ref", 60 - "ref": "#manualTriggerData" 61 - } 62 - } 63 - }, 64 - "triggerRepo": { 65 - "type": "object", 66 - "required": [ 67 - "knot", 68 - "did", 69 - "repo", 70 - "defaultBranch" 71 - ], 72 - "properties": { 73 - "knot": { 74 - "type": "string" 75 - }, 76 - "did": { 77 - "type": "string", 78 - "format": "did" 79 - }, 80 - "repo": { 81 - "type": "string" 82 - }, 83 - "defaultBranch": { 84 - "type": "string" 85 - } 86 - } 87 - }, 88 - "pushTriggerData": { 89 - "type": "object", 90 - "required": [ 91 - "ref", 92 - "newSha", 93 - "oldSha" 94 - ], 95 - "properties": { 96 - "ref": { 97 - "type": "string" 98 - }, 99 - "newSha": { 100 - "type": "string", 101 - "minLength": 40, 102 - "maxLength": 40 103 - }, 104 - "oldSha": { 105 - "type": "string", 106 - "minLength": 40, 107 - "maxLength": 40 108 - } 109 - } 110 - }, 111 - "pullRequestTriggerData": { 112 - "type": "object", 113 - "required": [ 114 - "sourceBranch", 115 - "targetBranch", 116 - "sourceSha", 117 - "action" 118 - ], 119 - "properties": { 120 - "sourceBranch": { 121 - "type": "string" 122 - }, 123 - "targetBranch": { 124 - "type": "string" 125 - }, 126 - "sourceSha": { 127 - "type": "string", 128 - "minLength": 40, 129 - "maxLength": 40 130 - }, 131 - "action": { 132 - "type": "string" 133 - } 134 - } 135 - }, 136 - "manualTriggerData": { 137 - "type": "object", 138 - "properties": { 139 - "inputs": { 140 - "type": "array", 141 - "items": { 142 - "type": "ref", 143 - "ref": "#pair" 144 - } 145 - } 146 - } 147 - }, 148 - "workflow": { 149 - "type": "object", 150 - "required": [ 151 - "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 156 - ], 157 - "properties": { 158 - "name": { 159 - "type": "string" 160 - }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 181 - }, 182 - "clone": { 183 - "type": "ref", 184 - "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 196 - "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 - } 204 - } 205 - }, 206 - "cloneOpts": { 207 - "type": "object", 208 - "required": [ 209 - "skip", 210 - "depth", 211 - "submodules" 212 - ], 213 - "properties": { 214 - "skip": { 215 - "type": "boolean" 216 - }, 217 - "depth": { 218 - "type": "integer" 219 - }, 220 - "submodules": { 221 - "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 - } 245 - } 246 - }, 247 - "pair": { 248 - "type": "object", 249 - "required": [ 250 - "key", 251 - "value" 252 - ], 253 - "properties": { 254 - "key": { 255 - "type": "string" 256 - }, 257 - "value": { 258 - "type": "string" 259 - } 260 - } 261 - } 262 - } 263 - }
+37
lexicons/repo/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/repo/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+29
lexicons/repo/defaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+67
lexicons/repo/listSecrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+31
lexicons/repo/removeSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+54
lexicons/repo/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "format": "datetime", 38 + "minGraphemes": 1, 39 + "maxGraphemes": 140 40 + }, 41 + "source": { 42 + "type": "string", 43 + "format": "uri", 44 + "description": "source of the repo" 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
+25
lexicons/spindle/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - } 25 -
+119 -59
nix/gomod2nix.toml
··· 11 11 version = "v0.6.2" 12 12 hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 13 [mod."github.com/ProtonMail/go-crypto"] 14 - version = "v1.2.0" 15 - hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo=" 14 + version = "v1.3.0" 15 + hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/alecthomas/assert/v2"] 17 + version = "v2.11.0" 18 + hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" 16 19 [mod."github.com/alecthomas/chroma/v2"] 17 20 version = "v2.19.0" 18 21 hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 19 22 replaced = "github.com/oppiliappan/chroma/v2" 23 + [mod."github.com/alecthomas/repr"] 24 + version = "v0.4.0" 25 + hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU=" 20 26 [mod."github.com/anmitsu/go-shlex"] 21 27 version = "v0.0.0-20200514113438-38f4b401e2be" 22 28 hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" ··· 34 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 35 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 36 42 [mod."github.com/bluesky-social/indigo"] 37 - version = "v0.0.0-20250520232546-236dd575c91e" 38 - hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0=" 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 39 45 [mod."github.com/bluesky-social/jetstream"] 40 46 version = "v0.0.0-20241210005130-ea96859b93d1" 41 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 51 57 [mod."github.com/casbin/govaluate"] 52 58 version = "v1.3.0" 53 59 hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 60 + [mod."github.com/cenkalti/backoff/v4"] 61 + version = "v4.3.0" 62 + hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8=" 54 63 [mod."github.com/cespare/xxhash/v2"] 55 64 version = "v2.3.0" 56 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 57 66 [mod."github.com/cloudflare/circl"] 58 - version = "v1.6.0" 59 - hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0=" 67 + version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 + hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 60 69 [mod."github.com/containerd/errdefs"] 61 70 version = "v1.0.0" 62 71 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 105 114 [mod."github.com/felixge/httpsnoop"] 106 115 version = "v1.0.4" 107 116 hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 117 + [mod."github.com/fsnotify/fsnotify"] 118 + version = "v1.6.0" 119 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 108 120 [mod."github.com/gliderlabs/ssh"] 109 121 version = "v0.3.8" 110 122 hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" ··· 127 139 version = "v5.17.0" 128 140 hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 129 141 replaced = "github.com/oppiliappan/go-git/v5" 142 + [mod."github.com/go-jose/go-jose/v3"] 143 + version = "v3.0.4" 144 + hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 130 145 [mod."github.com/go-logr/logr"] 131 - version = "v1.4.2" 132 - hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI=" 146 + version = "v1.4.3" 147 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 133 148 [mod."github.com/go-logr/stdr"] 134 149 version = "v1.2.2" 135 150 hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 136 151 [mod."github.com/go-redis/cache/v9"] 137 152 version = "v9.0.0" 138 153 hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 154 + [mod."github.com/go-test/deep"] 155 + version = "v1.1.1" 156 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 139 157 [mod."github.com/goccy/go-json"] 140 158 version = "v0.10.5" 141 159 hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" ··· 143 161 version = "v1.3.2" 144 162 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 145 163 [mod."github.com/golang-jwt/jwt/v5"] 146 - version = "v5.2.2" 147 - hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4=" 164 + version = "v5.2.3" 165 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 148 166 [mod."github.com/golang/groupcache"] 149 167 version = "v0.0.0-20241129210726-2c02b8208cf8" 150 168 hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 169 + [mod."github.com/golang/mock"] 170 + version = "v1.6.0" 171 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 151 172 [mod."github.com/google/uuid"] 152 173 version = "v1.6.0" 153 174 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" ··· 161 182 version = "v1.4.0" 162 183 hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 163 184 [mod."github.com/gorilla/websocket"] 164 - version = "v1.5.3" 165 - hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0=" 185 + version = "v1.5.4-0.20250319132907-e064f32e3674" 186 + hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to=" 187 + [mod."github.com/hashicorp/errwrap"] 188 + version = "v1.1.0" 189 + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" 166 190 [mod."github.com/hashicorp/go-cleanhttp"] 167 191 version = "v0.5.2" 168 192 hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 193 + [mod."github.com/hashicorp/go-multierror"] 194 + version = "v1.1.1" 195 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 169 196 [mod."github.com/hashicorp/go-retryablehttp"] 170 - version = "v0.7.7" 171 - hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU=" 197 + version = "v0.7.8" 198 + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 199 + [mod."github.com/hashicorp/go-secure-stdlib/parseutil"] 200 + version = "v0.2.0" 201 + hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8=" 202 + [mod."github.com/hashicorp/go-secure-stdlib/strutil"] 203 + version = "v0.1.2" 204 + hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A=" 205 + [mod."github.com/hashicorp/go-sockaddr"] 206 + version = "v1.0.7" 207 + hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 172 208 [mod."github.com/hashicorp/golang-lru"] 173 209 version = "v1.0.2" 174 210 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 175 211 [mod."github.com/hashicorp/golang-lru/v2"] 176 212 version = "v2.0.7" 177 213 hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 214 + [mod."github.com/hashicorp/hcl"] 215 + version = "v1.0.1-vault-7" 216 + hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM=" 217 + [mod."github.com/hexops/gotextdiff"] 218 + version = "v1.0.3" 219 + hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0=" 178 220 [mod."github.com/hiddeco/sshsig"] 179 221 version = "v0.2.0" 180 222 hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" ··· 185 227 version = "v0.0.4" 186 228 hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 187 229 [mod."github.com/ipfs/boxo"] 188 - version = "v0.30.0" 189 - hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848=" 230 + version = "v0.33.0" 231 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 190 232 [mod."github.com/ipfs/go-block-format"] 191 - version = "v0.2.1" 192 - hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8=" 233 + version = "v0.2.2" 234 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 193 235 [mod."github.com/ipfs/go-cid"] 194 236 version = "v0.5.0" 195 237 hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" ··· 203 245 version = "v1.1.1" 204 246 hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 205 247 [mod."github.com/ipfs/go-ipld-cbor"] 206 - version = "v0.2.0" 207 - hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc=" 248 + version = "v0.2.1" 249 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 208 250 [mod."github.com/ipfs/go-ipld-format"] 209 - version = "v0.6.1" 210 - hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4=" 251 + version = "v0.6.2" 252 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 211 253 [mod."github.com/ipfs/go-log"] 212 254 version = "v1.0.5" 213 255 hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" ··· 224 266 version = "v1.18.0" 225 267 hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 226 268 [mod."github.com/klauspost/cpuid/v2"] 227 - version = "v2.2.10" 228 - hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0=" 269 + version = "v2.3.0" 270 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 229 271 [mod."github.com/lestrrat-go/blackmagic"] 230 - version = "v1.0.3" 231 - hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw=" 272 + version = "v1.0.4" 273 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 232 274 [mod."github.com/lestrrat-go/httpcc"] 233 275 version = "v1.0.1" 234 276 hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" ··· 256 298 [mod."github.com/minio/sha256-simd"] 257 299 version = "v1.0.1" 258 300 hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 301 + [mod."github.com/mitchellh/mapstructure"] 302 + version = "v1.5.0" 303 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 259 304 [mod."github.com/moby/docker-image-spec"] 260 305 version = "v1.3.1" 261 306 hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" ··· 289 334 [mod."github.com/munnerz/goautoneg"] 290 335 version = "v0.0.0-20191010083416-a7dc8b61c822" 291 336 hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 337 + [mod."github.com/onsi/gomega"] 338 + version = "v1.37.0" 339 + hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o=" 340 + [mod."github.com/openbao/openbao/api/v2"] 341 + version = "v2.3.0" 342 + hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM=" 292 343 [mod."github.com/opencontainers/go-digest"] 293 344 version = "v1.0.0" 294 345 hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" ··· 296 347 version = "v1.1.1" 297 348 hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 298 349 [mod."github.com/opentracing/opentracing-go"] 299 - version = "v1.2.0" 300 - hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM=" 350 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 351 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 301 352 [mod."github.com/pjbgf/sha1cd"] 302 353 version = "v0.3.2" 303 354 hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" ··· 320 371 version = "v0.6.2" 321 372 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 322 373 [mod."github.com/prometheus/common"] 323 - version = "v0.63.0" 324 - hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE=" 374 + version = "v0.64.0" 375 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 325 376 [mod."github.com/prometheus/procfs"] 326 377 version = "v0.16.1" 327 378 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 328 379 [mod."github.com/redis/go-redis/v9"] 329 - version = "v9.3.0" 330 - hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w=" 380 + version = "v9.7.3" 381 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 331 382 [mod."github.com/resend/resend-go/v2"] 332 383 version = "v2.15.0" 333 384 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 385 + [mod."github.com/ryanuber/go-glob"] 386 + version = "v1.0.0" 387 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 334 388 [mod."github.com/segmentio/asm"] 335 389 version = "v1.2.0" 336 390 hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" ··· 375 429 version = "v1.1.0" 376 430 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 377 431 [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 378 - version = "v0.61.0" 379 - hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM=" 432 + version = "v0.62.0" 433 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 380 434 [mod."go.opentelemetry.io/otel"] 381 - version = "v1.36.0" 382 - hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko=" 435 + version = "v1.37.0" 436 + hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 437 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 438 + version = "v1.33.0" 439 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 383 440 [mod."go.opentelemetry.io/otel/metric"] 384 - version = "v1.36.0" 385 - hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8=" 441 + version = "v1.37.0" 442 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 386 443 [mod."go.opentelemetry.io/otel/trace"] 387 - version = "v1.36.0" 388 - hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA=" 444 + version = "v1.37.0" 445 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 389 446 [mod."go.opentelemetry.io/proto/otlp"] 390 447 version = "v1.6.0" 391 448 hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" ··· 399 456 version = "v1.27.0" 400 457 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 401 458 [mod."golang.org/x/crypto"] 402 - version = "v0.38.0" 403 - hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY=" 459 + version = "v0.40.0" 460 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 404 461 [mod."golang.org/x/exp"] 405 - version = "v0.0.0-20250408133849-7e4ce0ab07d0" 406 - hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8=" 462 + version = "v0.0.0-20250620022241-b7579e27df2b" 463 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 407 464 [mod."golang.org/x/net"] 408 - version = "v0.40.0" 409 - hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8=" 465 + version = "v0.42.0" 466 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 410 467 [mod."golang.org/x/sync"] 411 - version = "v0.14.0" 412 - hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4=" 468 + version = "v0.16.0" 469 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 413 470 [mod."golang.org/x/sys"] 414 - version = "v0.33.0" 415 - hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ=" 471 + version = "v0.34.0" 472 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 473 + [mod."golang.org/x/text"] 474 + version = "v0.27.0" 475 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 416 476 [mod."golang.org/x/time"] 417 - version = "v0.8.0" 418 - hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ=" 477 + version = "v0.12.0" 478 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 419 479 [mod."golang.org/x/xerrors"] 420 480 version = "v0.0.0-20240903120638-7835f813f4da" 421 481 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 422 482 [mod."google.golang.org/genproto/googleapis/api"] 423 - version = "v0.0.0-20250519155744-55703ea1f237" 424 - hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ=" 483 + version = "v0.0.0-20250603155806-513f23925822" 484 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 425 485 [mod."google.golang.org/genproto/googleapis/rpc"] 426 - version = "v0.0.0-20250519155744-55703ea1f237" 486 + version = "v0.0.0-20250603155806-513f23925822" 427 487 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 428 488 [mod."google.golang.org/grpc"] 429 - version = "v1.72.1" 430 - hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs=" 489 + version = "v1.73.0" 490 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 431 491 [mod."google.golang.org/protobuf"] 432 492 version = "v1.36.6" 433 493 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" ··· 450 510 version = "v1.4.1" 451 511 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 452 512 [mod."tangled.sh/icyphox.sh/atproto-oauth"] 453 - version = "v0.0.0-20250526154904-3906c5336421" 454 - hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM=" 513 + version = "v0.0.0-20250724194903-28e660378cb1" 514 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+40 -35
nix/modules/appview.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 - }: 7 - with lib; { 8 - options = { 9 - services.tangled-appview = { 10 - enable = mkOption { 11 - type = types.bool; 12 - default = false; 13 - description = "Enable tangled appview"; 14 - }; 15 - port = mkOption { 16 - type = types.int; 17 - default = 3000; 18 - description = "Port to run the appview on"; 19 - }; 20 - cookie_secret = mkOption { 21 - type = types.str; 22 - default = "00000000000000000000000000000000"; 23 - description = "Cookie secret"; 5 + }: let 6 + cfg = config.services.tangled-appview; 7 + in 8 + with lib; { 9 + options = { 10 + services.tangled-appview = { 11 + enable = mkOption { 12 + type = types.bool; 13 + default = false; 14 + description = "Enable tangled appview"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the appview"; 19 + }; 20 + port = mkOption { 21 + type = types.int; 22 + default = 3000; 23 + description = "Port to run the appview on"; 24 + }; 25 + cookie_secret = mkOption { 26 + type = types.str; 27 + default = "00000000000000000000000000000000"; 28 + description = "Cookie secret"; 29 + }; 24 30 }; 25 31 }; 26 - }; 27 32 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 33 + config = mkIf cfg.enable { 34 + systemd.services.tangled-appview = { 35 + description = "tangled appview service"; 36 + wantedBy = ["multi-user.target"]; 32 37 33 - serviceConfig = { 34 - ListenStream = "0.0.0.0:${toString config.services.tangled-appview.port}"; 35 - ExecStart = "${self.packages.${pkgs.system}.appview}/bin/appview"; 36 - Restart = "always"; 37 - }; 38 + serviceConfig = { 39 + ListenStream = "0.0.0.0:${toString cfg.port}"; 40 + ExecStart = "${cfg.package}/bin/appview"; 41 + Restart = "always"; 42 + }; 38 43 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 44 + environment = { 45 + TANGLED_DB_PATH = "appview.db"; 46 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 47 + }; 42 48 }; 43 49 }; 44 - }; 45 - } 50 + }
+45 -7
nix/modules/knot.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 3 pkgs, 4 4 lib, ··· 13 13 type = types.bool; 14 14 default = false; 15 15 description = "Enable a tangled knot"; 16 + }; 17 + 18 + package = mkOption { 19 + type = types.package; 20 + description = "Package to use for the knot"; 16 21 }; 17 22 18 23 appviewEndpoint = mkOption { ··· 53 58 }; 54 59 }; 55 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 56 83 server = { 57 84 listenAddr = mkOption { 58 85 type = types.str; ··· 94 121 }; 95 122 96 123 config = mkIf cfg.enable { 97 - environment.systemPackages = with pkgs; [ 98 - git 99 - self.packages."${pkgs.system}".knot 124 + environment.systemPackages = [ 125 + pkgs.git 126 + cfg.package 100 127 ]; 101 128 102 - system.activationScripts.gitConfig = '' 129 + system.activationScripts.gitConfig = let 130 + setMotd = 131 + if cfg.motdFile != null && cfg.motd != null 132 + then throw "motdFile and motd cannot be both set" 133 + else '' 134 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 135 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 136 + ''; 137 + in '' 103 138 mkdir -p "${cfg.repo.scanPath}" 104 139 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 105 140 ··· 108 143 [user] 109 144 name = Git User 110 145 email = git@example.com 146 + [receive] 147 + advertisePushOptions = true 111 148 EOF 149 + ${setMotd} 112 150 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 113 151 ''; 114 152 ··· 135 173 mode = "0555"; 136 174 text = '' 137 175 #!${pkgs.stdenv.shell} 138 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 176 + ${cfg.package}/bin/knot keys \ 139 177 -output authorized-keys \ 140 178 -internal-api "http://${cfg.server.internalListenAddr}" \ 141 179 -git-dir "${cfg.repo.scanPath}" \ ··· 160 198 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 161 199 ]; 162 200 EnvironmentFile = cfg.server.secretFile; 163 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 201 + ExecStart = "${cfg.package}/bin/knot server"; 164 202 Restart = "always"; 165 203 }; 166 204 };
+6 -3
nix/modules/spindle.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 5 }: let ··· 13 12 type = types.bool; 14 13 default = false; 15 14 description = "Enable a tangled spindle"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the spindle"; 16 19 }; 17 20 18 21 server = { ··· 89 92 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 93 "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 91 94 ]; 92 - ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 95 + ExecStart = "${cfg.package}/bin/spindle"; 93 96 Restart = "always"; 94 97 }; 95 98 };
+1
nix/vm.nix
··· 48 48 ]; 49 49 services.tangled-knot = { 50 50 enable = true; 51 + motd = "Welcome to the development knot!\n"; 51 52 server = { 52 53 secretFile = "/var/lib/knot/secret"; 53 54 hostname = "localhost:6000";
+4
rbac/rbac.go
··· 11 11 ) 12 12 13 13 const ( 14 + ThisServer = "thisserver" // resource identifier for local rbac enforcement 15 + ) 16 + 17 + const ( 14 18 Model = ` 15 19 [request_definition] 16 20 r = sub, dom, obj, act
+23 -6
spindle/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 9 11 type Server struct { 10 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 11 - DBPath string `env:"DB_PATH, default=spindle.db"` 12 - Hostname string `env:"HOSTNAME, required"` 13 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 14 - Dev bool `env:"DEV, default=false"` 15 - Owner string `env:"OWNER, required"` 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 + DBPath string `env:"DB_PATH, default=spindle.db"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + Dev bool `env:"DEV, default=false"` 17 + Owner string `env:"OWNER, required"` 18 + Secrets Secrets `env:",prefix=SECRETS_"` 19 + } 20 + 21 + func (s Server) Did() syntax.DID { 22 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 23 + } 24 + 25 + type Secrets struct { 26 + Provider string `env:"PROVIDER, default=sqlite"` 27 + OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` 28 + } 29 + 30 + type OpenBaoConfig struct { 31 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 32 + Mount string `env:"MOUNT, default=spindle"` 16 33 } 17 34 18 35 type Pipelines struct {
+42 -19
spindle/engine/engine.go
··· 11 11 "sync" 12 12 "time" 13 13 14 + securejoin "github.com/cyphar/filepath-securejoin" 14 15 "github.com/docker/docker/api/types/container" 15 16 "github.com/docker/docker/api/types/image" 16 17 "github.com/docker/docker/api/types/mount" ··· 18 19 "github.com/docker/docker/api/types/volume" 19 20 "github.com/docker/docker/client" 20 21 "github.com/docker/docker/pkg/stdcopy" 22 + "golang.org/x/sync/errgroup" 21 23 "tangled.sh/tangled.sh/core/log" 22 24 "tangled.sh/tangled.sh/core/notifier" 23 25 "tangled.sh/tangled.sh/core/spindle/config" 24 26 "tangled.sh/tangled.sh/core/spindle/db" 25 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 29 ) 27 30 28 31 const ( ··· 37 40 db *db.DB 38 41 n *notifier.Notifier 39 42 cfg *config.Config 43 + vault secrets.Manager 40 44 41 45 cleanupMu sync.Mutex 42 46 cleanup map[string][]cleanupFunc 43 47 } 44 48 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 49 + func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 46 50 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 51 if err != nil { 48 52 return nil, err ··· 56 60 db: db, 57 61 n: n, 58 62 cfg: cfg, 63 + vault: vault, 59 64 } 60 65 61 66 e.cleanup = make(map[string][]cleanupFunc) ··· 66 71 func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 72 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 73 69 - wg := sync.WaitGroup{} 74 + // extract secrets 75 + var allSecrets []secrets.UnlockedSecret 76 + if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 + if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 + allSecrets = res 79 + } 80 + } 81 + 82 + workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 83 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 84 + if err != nil { 85 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 86 + workflowTimeout = 5 * time.Minute 87 + } 88 + e.l.Info("using workflow timeout", "timeout", workflowTimeout) 89 + 90 + eg, ctx := errgroup.WithContext(ctx) 70 91 for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 92 + eg.Go(func() error { 74 93 wid := models.WorkflowId{ 75 94 PipelineId: pipelineId, 76 95 Name: w.Name, ··· 102 121 defer reader.Close() 103 122 io.Copy(os.Stdout, reader) 104 123 105 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 106 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 107 - if err != nil { 108 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 109 - workflowTimeout = 5 * time.Minute 110 - } 111 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 112 124 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 125 defer cancel() 114 126 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 127 + err = e.StartSteps(ctx, wid, w, allSecrets) 116 128 if err != nil { 117 129 if errors.Is(err, ErrTimedOut) { 118 130 dbErr := e.db.StatusTimeout(wid, e.n) ··· 135 147 } 136 148 137 149 return nil 138 - }() 150 + }) 139 151 } 140 152 141 - wg.Wait() 153 + if err = eg.Wait(); err != nil { 154 + e.l.Error("failed to run one or more workflows", "err", err) 155 + } else { 156 + e.l.Error("successfully ran full pipeline") 157 + } 142 158 } 143 159 144 160 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 186 202 // ONLY marks pipeline as failed if container's exit code is non-zero. 187 203 // All other errors are bubbled up. 188 204 // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 205 + func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 206 + workflowEnvs := ConstructEnvs(w.Environment) 207 + for _, s := range secrets { 208 + workflowEnvs.AddEnv(s.Key, s.Value) 209 + } 190 210 191 - for stepIdx, step := range steps { 211 + for stepIdx, step := range w.Steps { 192 212 select { 193 213 case <-ctx.Done(): 194 214 return ctx.Err() 195 215 default: 196 216 } 197 217 198 - envs := ConstructEnvs(step.Environment) 218 + envs := append(EnvVars(nil), workflowEnvs...) 219 + for k, v := range step.Environment { 220 + envs.AddEnv(k, v) 221 + } 199 222 envs.AddEnv("HOME", workspaceDir) 200 223 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 224 202 225 hostConfig := hostConfig(wid) 203 226 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 227 + Image: w.Image, 205 228 Cmd: []string{"bash", "-c", step.Command}, 206 229 WorkingDir: workspaceDir, 207 230 Tty: false,
+129 -2
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 8 9 "tangled.sh/tangled.sh/core/api/tangled" 9 10 "tangled.sh/tangled.sh/core/eventconsumer" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/rbac" 10 13 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/bluesky-social/indigo/xrpc" 11 18 "github.com/bluesky-social/jetstream/pkg/models" 19 + securejoin "github.com/cyphar/filepath-securejoin" 12 20 ) 13 21 14 22 type Ingester func(ctx context.Context, e *models.Event) error ··· 33 41 s.ingestMember(ctx, e) 34 42 case tangled.RepoNSID: 35 43 s.ingestRepo(ctx, e) 44 + case tangled.RepoCollaboratorNSID: 45 + s.ingestCollaborator(ctx, e) 36 46 } 37 47 38 48 return err ··· 72 82 return fmt.Errorf("failed to enforce permissions: %w", err) 73 83 } 74 84 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 85 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 76 86 l.Error("failed to add member", "error", err) 77 87 return fmt.Errorf("failed to add member: %w", err) 78 88 } ··· 90 100 return nil 91 101 } 92 102 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 103 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 94 104 var err error 105 + did := e.Did 106 + resolver := idresolver.DefaultResolver() 95 107 96 108 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 109 ··· 127 139 return fmt.Errorf("failed to add repo: %w", err) 128 140 } 129 141 142 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 143 + if err != nil { 144 + return err 145 + } 146 + 147 + // add repo to rbac 148 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 149 + l.Error("failed to add repo to enforcer", "error", err) 150 + return fmt.Errorf("failed to add repo: %w", err) 151 + } 152 + 153 + // add collaborators to rbac 154 + owner, err := resolver.ResolveIdent(ctx, did) 155 + if err != nil || owner.Handle.IsInvalidHandle() { 156 + return err 157 + } 158 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 159 + return err 160 + } 161 + 130 162 // add this knot to the event consumer 131 163 src := eventconsumer.NewKnotSource(record.Knot) 132 164 s.ks.AddSource(context.Background(), src) ··· 136 168 } 137 169 return nil 138 170 } 171 + 172 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 173 + var err error 174 + 175 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 176 + 177 + l.Info("ingesting collaborator record") 178 + 179 + switch e.Commit.Operation { 180 + case models.CommitOperationCreate, models.CommitOperationUpdate: 181 + raw := e.Commit.Record 182 + record := tangled.RepoCollaborator{} 183 + err = json.Unmarshal(raw, &record) 184 + if err != nil { 185 + l.Error("invalid record", "error", err) 186 + return err 187 + } 188 + 189 + resolver := idresolver.DefaultResolver() 190 + 191 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 192 + if err != nil || subjectId.Handle.IsInvalidHandle() { 193 + return err 194 + } 195 + 196 + repoAt, err := syntax.ParseATURI(record.Repo) 197 + if err != nil { 198 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 199 + return nil 200 + } 201 + 202 + // TODO: get rid of this entirely 203 + // resolve this aturi to extract the repo record 204 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 205 + if err != nil || owner.Handle.IsInvalidHandle() { 206 + return fmt.Errorf("failed to resolve handle: %w", err) 207 + } 208 + 209 + xrpcc := xrpc.Client{ 210 + Host: owner.PDSEndpoint(), 211 + } 212 + 213 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 214 + if err != nil { 215 + return err 216 + } 217 + 218 + repo := resp.Value.Val.(*tangled.Repo) 219 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 220 + 221 + // check perms for this user 222 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 223 + return fmt.Errorf("insufficient permissions: %w", err) 224 + } 225 + 226 + // add collaborator to rbac 227 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 228 + l.Error("failed to add repo to enforcer", "error", err) 229 + return fmt.Errorf("failed to add repo: %w", err) 230 + } 231 + 232 + return nil 233 + } 234 + return nil 235 + } 236 + 237 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 238 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 239 + 240 + l.Info("fetching and adding existing collaborators") 241 + 242 + xrpcc := xrpc.Client{ 243 + Host: owner.PDSEndpoint(), 244 + } 245 + 246 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 247 + if err != nil { 248 + return err 249 + } 250 + 251 + var errs error 252 + for _, r := range resp.Records { 253 + if r == nil { 254 + continue 255 + } 256 + record := r.Value.Val.(*tangled.RepoCollaborator) 257 + 258 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 259 + l.Error("failed to add repo to enforcer", "error", err) 260 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 261 + } 262 + } 263 + 264 + return errs 265 + }
+9 -12
spindle/models/pipeline.go
··· 8 8 ) 9 9 10 10 type Pipeline struct { 11 + RepoOwner string 12 + RepoName string 11 13 Workflows []Workflow 12 14 } 13 15 ··· 63 65 swf.Environment = workflowEnvToMap(twf.Environment) 64 66 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 67 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 68 setup := &setupSteps{} 69 69 70 70 setup.addStep(nixConfStep()) ··· 79 79 80 80 workflows = append(workflows, *swf) 81 81 } 82 - return &Pipeline{Workflows: workflows} 82 + repoOwner := pl.TriggerMetadata.Repo.Did 83 + repoName := pl.TriggerMetadata.Repo.Repo 84 + return &Pipeline{ 85 + RepoOwner: repoOwner, 86 + RepoName: repoName, 87 + Workflows: workflows, 88 + } 83 89 } 84 90 85 91 func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { ··· 115 121 116 122 return path.Join(nixery, dependencies) 117 123 } 118 - 119 - func (wf *Workflow) addNixProfileToPath() { 120 - wf.Environment["PATH"] = "$PATH:/.nix-profile/bin" 121 - } 122 - 123 - func (wf *Workflow) setGlobalEnvs() { 124 - wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes" 125 - wf.Environment["HOME"] = "/tangled/workspace" 126 - }
+3
spindle/models/setup_steps.go
··· 102 102 continue 103 103 } 104 104 105 + if len(packages) == 0 { 106 + customPackages = append(customPackages, registry) 107 + } 105 108 // collect packages from custom registries 106 109 for _, pkg := range packages { 107 110 customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
+25
spindle/motd
··· 1 + **** 2 + *** *** 3 + *** ** ****** ** 4 + ** * ***** 5 + * ** ** 6 + * * * *************** 7 + ** ** *# ** 8 + * ** ** *** ** 9 + * * ** ** * ****** 10 + * ** ** * ** * * 11 + ** ** *** ** ** * 12 + ** ** * ** * * 13 + ** **** ** * * 14 + ** *** ** ** ** 15 + *** ** ***** 16 + ******************** 17 + ** 18 + * 19 + #************** 20 + ** 21 + ******** 22 + 23 + This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 + 25 + Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "regexp" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + type DidSlashRepo string 13 + 14 + type Secret[T any] struct { 15 + Key string 16 + Value T 17 + Repo DidSlashRepo 18 + CreatedAt time.Time 19 + CreatedBy syntax.DID 20 + } 21 + 22 + // the secret is not present 23 + type LockedSecret = Secret[struct{}] 24 + 25 + // the secret is present in plaintext, never expose this publicly, 26 + // only use in the workflow engine 27 + type UnlockedSecret = Secret[string] 28 + 29 + type Manager interface { 30 + AddSecret(ctx context.Context, secret UnlockedSecret) error 31 + RemoveSecret(ctx context.Context, secret Secret[any]) error 32 + GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) 33 + GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) 34 + } 35 + 36 + // stopper interface for managers that need cleanup 37 + type Stopper interface { 38 + Stop() 39 + } 40 + 41 + var ErrKeyAlreadyPresent = errors.New("key already present") 42 + var ErrInvalidKeyIdent = errors.New("key is not a valid identifier") 43 + var ErrKeyNotFound = errors.New("key not found") 44 + 45 + // ensure that we are satisfying the interface 46 + var ( 47 + _ = []Manager{ 48 + &SqliteManager{}, 49 + &OpenBaoManager{}, 50 + } 51 + ) 52 + 53 + var ( 54 + // bash identifier syntax 55 + keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) 56 + ) 57 + 58 + func isValidKey(key string) bool { 59 + if key == "" { 60 + return false 61 + } 62 + return keyIdent.MatchString(key) 63 + } 64 + 65 + func ValidateKey(key string) error { 66 + if !isValidKey(key) { 67 + return ErrInvalidKeyIdent 68 + } 69 + return nil 70 + }
+313
spindle/secrets/openbao.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "path" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + vault "github.com/openbao/openbao/api/v2" 13 + ) 14 + 15 + type OpenBaoManager struct { 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + } 20 + 21 + type OpenBaoManagerOpt func(*OpenBaoManager) 22 + 23 + func WithMountPath(mountPath string) OpenBaoManagerOpt { 24 + return func(v *OpenBaoManager) { 25 + v.mountPath = mountPath 26 + } 27 + } 28 + 29 + // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 + // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 + // The proxy handles all authentication automatically via Auto-Auth 32 + func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 + if proxyAddress == "" { 34 + return nil, fmt.Errorf("proxy address cannot be empty") 35 + } 36 + 37 + config := vault.DefaultConfig() 38 + config.Address = proxyAddress 39 + 40 + client, err := vault.NewClient(config) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create openbao client: %w", err) 43 + } 44 + 45 + manager := &OpenBaoManager{ 46 + client: client, 47 + mountPath: "spindle", // default KV v2 mount path 48 + logger: logger, 49 + } 50 + 51 + for _, opt := range opts { 52 + opt(manager) 53 + } 54 + 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 57 + } 58 + 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 61 + } 62 + 63 + // testConnection verifies that we can connect to the proxy 64 + func (v *OpenBaoManager) testConnection() error { 65 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 + defer cancel() 67 + 68 + // try token self-lookup as a quick way to verify proxy works 69 + // and is authenticated 70 + _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 71 + if err != nil { 72 + return fmt.Errorf("proxy connection test failed: %w", err) 73 + } 74 + 75 + return nil 76 + } 77 + 78 + func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 79 + if err := ValidateKey(secret.Key); err != nil { 80 + return err 81 + } 82 + 83 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 85 + 86 + // Check if secret already exists 87 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 88 + if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 90 + return ErrKeyAlreadyPresent 91 + } 92 + 93 + secretData := map[string]interface{}{ 94 + "value": secret.Value, 95 + "repo": string(secret.Repo), 96 + "key": secret.Key, 97 + "created_at": secret.CreatedAt.Format(time.RFC3339), 98 + "created_by": secret.CreatedBy.String(), 99 + } 100 + 101 + v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 + resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 103 + if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 105 + return fmt.Errorf("failed to store secret in openbao: %w", err) 106 + } 107 + 108 + v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 + 110 + v.logger.Debug("verifying secret was written", "path", secretPath) 111 + readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 + if err != nil { 113 + v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 + return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 + } 116 + 117 + if readBack == nil || readBack.Data == nil { 118 + v.logger.Error("secret verification returned empty data", "path", secretPath) 119 + return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 + } 121 + 122 + v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 123 + return nil 124 + } 125 + 126 + func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 127 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 128 + 129 + // check if secret exists 130 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 131 + if err != nil || existing == nil { 132 + return ErrKeyNotFound 133 + } 134 + 135 + err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 136 + if err != nil { 137 + return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 + } 139 + 140 + v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 141 + return nil 142 + } 143 + 144 + func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 145 + repoPath := v.buildRepoPath(repo) 146 + 147 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 148 + if err != nil { 149 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 150 + return []LockedSecret{}, nil 151 + } 152 + return nil, fmt.Errorf("failed to list secrets: %w", err) 153 + } 154 + 155 + if secretsList == nil || secretsList.Data == nil { 156 + return []LockedSecret{}, nil 157 + } 158 + 159 + keys, ok := secretsList.Data["keys"].([]interface{}) 160 + if !ok { 161 + return []LockedSecret{}, nil 162 + } 163 + 164 + var secrets []LockedSecret 165 + 166 + for _, keyInterface := range keys { 167 + key, ok := keyInterface.(string) 168 + if !ok { 169 + continue 170 + } 171 + 172 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 173 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 174 + if err != nil { 175 + v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 + continue 177 + } 178 + 179 + if secretData == nil || secretData.Data == nil { 180 + continue 181 + } 182 + 183 + data := secretData.Data 184 + 185 + createdAtStr, ok := data["created_at"].(string) 186 + if !ok { 187 + createdAtStr = time.Now().Format(time.RFC3339) 188 + } 189 + 190 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 191 + if err != nil { 192 + createdAt = time.Now() 193 + } 194 + 195 + createdByStr, ok := data["created_by"].(string) 196 + if !ok { 197 + createdByStr = "" 198 + } 199 + 200 + keyStr, ok := data["key"].(string) 201 + if !ok { 202 + keyStr = key 203 + } 204 + 205 + secret := LockedSecret{ 206 + Key: keyStr, 207 + Repo: repo, 208 + CreatedAt: createdAt, 209 + CreatedBy: syntax.DID(createdByStr), 210 + } 211 + 212 + secrets = append(secrets, secret) 213 + } 214 + 215 + v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 216 + return secrets, nil 217 + } 218 + 219 + func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 220 + repoPath := v.buildRepoPath(repo) 221 + 222 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 223 + if err != nil { 224 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 225 + return []UnlockedSecret{}, nil 226 + } 227 + return nil, fmt.Errorf("failed to list secrets: %w", err) 228 + } 229 + 230 + if secretsList == nil || secretsList.Data == nil { 231 + return []UnlockedSecret{}, nil 232 + } 233 + 234 + keys, ok := secretsList.Data["keys"].([]interface{}) 235 + if !ok { 236 + return []UnlockedSecret{}, nil 237 + } 238 + 239 + var secrets []UnlockedSecret 240 + 241 + for _, keyInterface := range keys { 242 + key, ok := keyInterface.(string) 243 + if !ok { 244 + continue 245 + } 246 + 247 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 248 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 249 + if err != nil { 250 + v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 251 + continue 252 + } 253 + 254 + if secretData == nil || secretData.Data == nil { 255 + continue 256 + } 257 + 258 + data := secretData.Data 259 + 260 + valueStr, ok := data["value"].(string) 261 + if !ok { 262 + v.logger.Warn("secret missing value", "path", secretPath) 263 + continue 264 + } 265 + 266 + createdAtStr, ok := data["created_at"].(string) 267 + if !ok { 268 + createdAtStr = time.Now().Format(time.RFC3339) 269 + } 270 + 271 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 272 + if err != nil { 273 + createdAt = time.Now() 274 + } 275 + 276 + createdByStr, ok := data["created_by"].(string) 277 + if !ok { 278 + createdByStr = "" 279 + } 280 + 281 + keyStr, ok := data["key"].(string) 282 + if !ok { 283 + keyStr = key 284 + } 285 + 286 + secret := UnlockedSecret{ 287 + Key: keyStr, 288 + Value: valueStr, 289 + Repo: repo, 290 + CreatedAt: createdAt, 291 + CreatedBy: syntax.DID(createdByStr), 292 + } 293 + 294 + secrets = append(secrets, secret) 295 + } 296 + 297 + v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 298 + return secrets, nil 299 + } 300 + 301 + // buildRepoPath creates a safe path for a repository 302 + func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 303 + // convert DidSlashRepo to a safe path by replacing special characters 304 + repoPath := strings.ReplaceAll(string(repo), "/", "_") 305 + repoPath = strings.ReplaceAll(repoPath, ":", "_") 306 + repoPath = strings.ReplaceAll(repoPath, ".", "_") 307 + return fmt.Sprintf("repos/%s", repoPath) 308 + } 309 + 310 + // buildSecretPath creates a path for a specific secret 311 + func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 312 + return path.Join(v.buildRepoPath(repo), key) 313 + }
+605
spindle/secrets/openbao_test.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + "testing" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + // MockOpenBaoManager is a mock implementation of Manager interface for testing 15 + type MockOpenBaoManager struct { 16 + secrets map[string]UnlockedSecret // key: repo_key format 17 + shouldError bool 18 + errorToReturn error 19 + } 20 + 21 + func NewMockOpenBaoManager() *MockOpenBaoManager { 22 + return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)} 23 + } 24 + 25 + func (m *MockOpenBaoManager) SetError(err error) { 26 + m.shouldError = true 27 + m.errorToReturn = err 28 + } 29 + 30 + func (m *MockOpenBaoManager) ClearError() { 31 + m.shouldError = false 32 + m.errorToReturn = nil 33 + } 34 + 35 + func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { 36 + return string(repo) + "_" + key 37 + } 38 + 39 + func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 40 + if m.shouldError { 41 + return m.errorToReturn 42 + } 43 + 44 + key := m.buildKey(secret.Repo, secret.Key) 45 + if _, exists := m.secrets[key]; exists { 46 + return ErrKeyAlreadyPresent 47 + } 48 + 49 + m.secrets[key] = secret 50 + return nil 51 + } 52 + 53 + func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 54 + if m.shouldError { 55 + return m.errorToReturn 56 + } 57 + 58 + key := m.buildKey(secret.Repo, secret.Key) 59 + if _, exists := m.secrets[key]; !exists { 60 + return ErrKeyNotFound 61 + } 62 + 63 + delete(m.secrets, key) 64 + return nil 65 + } 66 + 67 + func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 68 + if m.shouldError { 69 + return nil, m.errorToReturn 70 + } 71 + 72 + var result []LockedSecret 73 + for _, secret := range m.secrets { 74 + if secret.Repo == repo { 75 + result = append(result, LockedSecret{ 76 + Key: secret.Key, 77 + Repo: secret.Repo, 78 + CreatedAt: secret.CreatedAt, 79 + CreatedBy: secret.CreatedBy, 80 + }) 81 + } 82 + } 83 + 84 + return result, nil 85 + } 86 + 87 + func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 88 + if m.shouldError { 89 + return nil, m.errorToReturn 90 + } 91 + 92 + var result []UnlockedSecret 93 + for _, secret := range m.secrets { 94 + if secret.Repo == repo { 95 + result = append(result, secret) 96 + } 97 + } 98 + 99 + return result, nil 100 + } 101 + 102 + func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret { 103 + return UnlockedSecret{ 104 + Key: key, 105 + Value: value, 106 + Repo: DidSlashRepo(repo), 107 + CreatedAt: time.Now(), 108 + CreatedBy: syntax.DID(createdBy), 109 + } 110 + } 111 + 112 + // Test MockOpenBaoManager interface compliance 113 + func TestMockOpenBaoManagerInterface(t *testing.T) { 114 + var _ Manager = (*MockOpenBaoManager)(nil) 115 + } 116 + 117 + func TestOpenBaoManagerInterface(t *testing.T) { 118 + var _ Manager = (*OpenBaoManager)(nil) 119 + } 120 + 121 + func TestNewOpenBaoManager(t *testing.T) { 122 + tests := []struct { 123 + name string 124 + proxyAddr string 125 + opts []OpenBaoManagerOpt 126 + expectError bool 127 + errorContains string 128 + }{ 129 + { 130 + name: "empty proxy address", 131 + proxyAddr: "", 132 + opts: nil, 133 + expectError: true, 134 + errorContains: "proxy address cannot be empty", 135 + }, 136 + { 137 + name: "valid proxy address", 138 + proxyAddr: "http://localhost:8200", 139 + opts: nil, 140 + expectError: true, // Will fail because no real proxy is running 141 + errorContains: "failed to connect to bao proxy", 142 + }, 143 + { 144 + name: "with mount path option", 145 + proxyAddr: "http://localhost:8200", 146 + opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")}, 147 + expectError: true, // Will fail because no real proxy is running 148 + errorContains: "failed to connect to bao proxy", 149 + }, 150 + } 151 + 152 + for _, tt := range tests { 153 + t.Run(tt.name, func(t *testing.T) { 154 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 156 + 157 + if tt.expectError { 158 + assert.Error(t, err) 159 + assert.Nil(t, manager) 160 + assert.Contains(t, err.Error(), tt.errorContains) 161 + } else { 162 + assert.NoError(t, err) 163 + assert.NotNil(t, manager) 164 + } 165 + }) 166 + } 167 + } 168 + 169 + func TestOpenBaoManager_PathBuilding(t *testing.T) { 170 + manager := &OpenBaoManager{mountPath: "secret"} 171 + 172 + tests := []struct { 173 + name string 174 + repo DidSlashRepo 175 + key string 176 + expected string 177 + }{ 178 + { 179 + name: "simple repo path", 180 + repo: DidSlashRepo("did:plc:foo/repo"), 181 + key: "api_key", 182 + expected: "repos/did_plc_foo_repo/api_key", 183 + }, 184 + { 185 + name: "complex repo path with dots", 186 + repo: DidSlashRepo("did:web:example.com/my-repo"), 187 + key: "secret_key", 188 + expected: "repos/did_web_example_com_my-repo/secret_key", 189 + }, 190 + } 191 + 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + result := manager.buildSecretPath(tt.repo, tt.key) 195 + assert.Equal(t, tt.expected, result) 196 + }) 197 + } 198 + } 199 + 200 + func TestOpenBaoManager_buildRepoPath(t *testing.T) { 201 + manager := &OpenBaoManager{mountPath: "test"} 202 + 203 + tests := []struct { 204 + name string 205 + repo DidSlashRepo 206 + expected string 207 + }{ 208 + { 209 + name: "simple repo", 210 + repo: "did:plc:test/myrepo", 211 + expected: "repos/did_plc_test_myrepo", 212 + }, 213 + { 214 + name: "repo with dots", 215 + repo: "did:plc:example.com/my.repo", 216 + expected: "repos/did_plc_example_com_my_repo", 217 + }, 218 + { 219 + name: "complex repo", 220 + repo: "did:web:example.com:8080/path/to/repo", 221 + expected: "repos/did_web_example_com_8080_path_to_repo", 222 + }, 223 + } 224 + 225 + for _, tt := range tests { 226 + t.Run(tt.name, func(t *testing.T) { 227 + result := manager.buildRepoPath(tt.repo) 228 + assert.Equal(t, tt.expected, result) 229 + }) 230 + } 231 + } 232 + 233 + func TestWithMountPath(t *testing.T) { 234 + manager := &OpenBaoManager{mountPath: "default"} 235 + 236 + opt := WithMountPath("custom-mount") 237 + opt(manager) 238 + 239 + assert.Equal(t, "custom-mount", manager.mountPath) 240 + } 241 + 242 + func TestMockOpenBaoManager_AddSecret(t *testing.T) { 243 + tests := []struct { 244 + name string 245 + secrets []UnlockedSecret 246 + expectError bool 247 + }{ 248 + { 249 + name: "add single secret", 250 + secrets: []UnlockedSecret{ 251 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 252 + }, 253 + expectError: false, 254 + }, 255 + { 256 + name: "add multiple secrets", 257 + secrets: []UnlockedSecret{ 258 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 259 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 260 + }, 261 + expectError: false, 262 + }, 263 + { 264 + name: "add duplicate secret", 265 + secrets: []UnlockedSecret{ 266 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 267 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"), 268 + }, 269 + expectError: true, 270 + }, 271 + } 272 + 273 + for _, tt := range tests { 274 + t.Run(tt.name, func(t *testing.T) { 275 + mock := NewMockOpenBaoManager() 276 + ctx := context.Background() 277 + var err error 278 + 279 + for i, secret := range tt.secrets { 280 + err = mock.AddSecret(ctx, secret) 281 + if tt.expectError && i == 1 { // Second secret should fail for duplicate test 282 + assert.Equal(t, ErrKeyAlreadyPresent, err) 283 + return 284 + } 285 + if !tt.expectError { 286 + assert.NoError(t, err) 287 + } 288 + } 289 + 290 + if !tt.expectError { 291 + assert.NoError(t, err) 292 + } 293 + }) 294 + } 295 + } 296 + 297 + func TestMockOpenBaoManager_RemoveSecret(t *testing.T) { 298 + tests := []struct { 299 + name string 300 + setupSecrets []UnlockedSecret 301 + removeSecret Secret[any] 302 + expectError bool 303 + }{ 304 + { 305 + name: "remove existing secret", 306 + setupSecrets: []UnlockedSecret{ 307 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 308 + }, 309 + removeSecret: Secret[any]{ 310 + Key: "API_KEY", 311 + Repo: DidSlashRepo("did:plc:test/repo1"), 312 + }, 313 + expectError: false, 314 + }, 315 + { 316 + name: "remove non-existent secret", 317 + setupSecrets: []UnlockedSecret{}, 318 + removeSecret: Secret[any]{ 319 + Key: "API_KEY", 320 + Repo: DidSlashRepo("did:plc:test/repo1"), 321 + }, 322 + expectError: true, 323 + }, 324 + } 325 + 326 + for _, tt := range tests { 327 + t.Run(tt.name, func(t *testing.T) { 328 + mock := NewMockOpenBaoManager() 329 + ctx := context.Background() 330 + 331 + // Setup secrets 332 + for _, secret := range tt.setupSecrets { 333 + err := mock.AddSecret(ctx, secret) 334 + assert.NoError(t, err) 335 + } 336 + 337 + // Remove secret 338 + err := mock.RemoveSecret(ctx, tt.removeSecret) 339 + 340 + if tt.expectError { 341 + assert.Equal(t, ErrKeyNotFound, err) 342 + } else { 343 + assert.NoError(t, err) 344 + } 345 + }) 346 + } 347 + } 348 + 349 + func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) { 350 + tests := []struct { 351 + name string 352 + setupSecrets []UnlockedSecret 353 + queryRepo DidSlashRepo 354 + expectedCount int 355 + expectedKeys []string 356 + expectError bool 357 + }{ 358 + { 359 + name: "get secrets from repo with secrets", 360 + setupSecrets: []UnlockedSecret{ 361 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 362 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 363 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 364 + }, 365 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 366 + expectedCount: 2, 367 + expectedKeys: []string{"API_KEY", "DB_PASSWORD"}, 368 + expectError: false, 369 + }, 370 + { 371 + name: "get secrets from empty repo", 372 + setupSecrets: []UnlockedSecret{}, 373 + queryRepo: DidSlashRepo("did:plc:test/empty"), 374 + expectedCount: 0, 375 + expectedKeys: []string{}, 376 + expectError: false, 377 + }, 378 + } 379 + 380 + for _, tt := range tests { 381 + t.Run(tt.name, func(t *testing.T) { 382 + mock := NewMockOpenBaoManager() 383 + ctx := context.Background() 384 + 385 + // Setup 386 + for _, secret := range tt.setupSecrets { 387 + err := mock.AddSecret(ctx, secret) 388 + assert.NoError(t, err) 389 + } 390 + 391 + // Test 392 + secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo) 393 + 394 + if tt.expectError { 395 + assert.Error(t, err) 396 + } else { 397 + assert.NoError(t, err) 398 + assert.Len(t, secrets, tt.expectedCount) 399 + 400 + // Check keys 401 + actualKeys := make([]string, len(secrets)) 402 + for i, secret := range secrets { 403 + actualKeys[i] = secret.Key 404 + } 405 + 406 + for _, expectedKey := range tt.expectedKeys { 407 + assert.Contains(t, actualKeys, expectedKey) 408 + } 409 + } 410 + }) 411 + } 412 + } 413 + 414 + func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) { 415 + tests := []struct { 416 + name string 417 + setupSecrets []UnlockedSecret 418 + queryRepo DidSlashRepo 419 + expectedCount int 420 + expectedSecrets map[string]string // key -> value 421 + expectError bool 422 + }{ 423 + { 424 + name: "get unlocked secrets from repo", 425 + setupSecrets: []UnlockedSecret{ 426 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 427 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 428 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 429 + }, 430 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 431 + expectedCount: 2, 432 + expectedSecrets: map[string]string{ 433 + "API_KEY": "secret123", 434 + "DB_PASSWORD": "dbpass456", 435 + }, 436 + expectError: false, 437 + }, 438 + { 439 + name: "get secrets from empty repo", 440 + setupSecrets: []UnlockedSecret{}, 441 + queryRepo: DidSlashRepo("did:plc:test/empty"), 442 + expectedCount: 0, 443 + expectedSecrets: map[string]string{}, 444 + expectError: false, 445 + }, 446 + } 447 + 448 + for _, tt := range tests { 449 + t.Run(tt.name, func(t *testing.T) { 450 + mock := NewMockOpenBaoManager() 451 + ctx := context.Background() 452 + 453 + // Setup 454 + for _, secret := range tt.setupSecrets { 455 + err := mock.AddSecret(ctx, secret) 456 + assert.NoError(t, err) 457 + } 458 + 459 + // Test 460 + secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo) 461 + 462 + if tt.expectError { 463 + assert.Error(t, err) 464 + } else { 465 + assert.NoError(t, err) 466 + assert.Len(t, secrets, tt.expectedCount) 467 + 468 + // Check key-value pairs 469 + actualSecrets := make(map[string]string) 470 + for _, secret := range secrets { 471 + actualSecrets[secret.Key] = secret.Value 472 + } 473 + 474 + for expectedKey, expectedValue := range tt.expectedSecrets { 475 + actualValue, exists := actualSecrets[expectedKey] 476 + assert.True(t, exists, "Expected key %s not found", expectedKey) 477 + assert.Equal(t, expectedValue, actualValue) 478 + } 479 + } 480 + }) 481 + } 482 + } 483 + 484 + func TestMockOpenBaoManager_ErrorHandling(t *testing.T) { 485 + mock := NewMockOpenBaoManager() 486 + ctx := context.Background() 487 + testError := assert.AnError 488 + 489 + // Test error injection 490 + mock.SetError(testError) 491 + 492 + secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator") 493 + 494 + // All operations should return the injected error 495 + err := mock.AddSecret(ctx, secret) 496 + assert.Equal(t, testError, err) 497 + 498 + _, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1") 499 + assert.Equal(t, testError, err) 500 + 501 + _, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1") 502 + assert.Equal(t, testError, err) 503 + 504 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"}) 505 + assert.Equal(t, testError, err) 506 + 507 + // Clear error and test normal operation 508 + mock.ClearError() 509 + err = mock.AddSecret(ctx, secret) 510 + assert.NoError(t, err) 511 + } 512 + 513 + func TestMockOpenBaoManager_Integration(t *testing.T) { 514 + tests := []struct { 515 + name string 516 + scenario func(t *testing.T, mock *MockOpenBaoManager) 517 + }{ 518 + { 519 + name: "complete workflow", 520 + scenario: func(t *testing.T, mock *MockOpenBaoManager) { 521 + ctx := context.Background() 522 + repo := DidSlashRepo("did:plc:test/integration") 523 + 524 + // Start with empty repo 525 + secrets, err := mock.GetSecretsLocked(ctx, repo) 526 + assert.NoError(t, err) 527 + assert.Empty(t, secrets) 528 + 529 + // Add some secrets 530 + secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator") 531 + secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator") 532 + 533 + err = mock.AddSecret(ctx, secret1) 534 + assert.NoError(t, err) 535 + 536 + err = mock.AddSecret(ctx, secret2) 537 + assert.NoError(t, err) 538 + 539 + // Verify secrets exist 540 + secrets, err = mock.GetSecretsLocked(ctx, repo) 541 + assert.NoError(t, err) 542 + assert.Len(t, secrets, 2) 543 + 544 + unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo) 545 + assert.NoError(t, err) 546 + assert.Len(t, unlockedSecrets, 2) 547 + 548 + // Remove one secret 549 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo}) 550 + assert.NoError(t, err) 551 + 552 + // Verify only one secret remains 553 + secrets, err = mock.GetSecretsLocked(ctx, repo) 554 + assert.NoError(t, err) 555 + assert.Len(t, secrets, 1) 556 + assert.Equal(t, "DB_PASSWORD", secrets[0].Key) 557 + }, 558 + }, 559 + } 560 + 561 + for _, tt := range tests { 562 + t.Run(tt.name, func(t *testing.T) { 563 + mock := NewMockOpenBaoManager() 564 + tt.scenario(t, mock) 565 + }) 566 + } 567 + } 568 + 569 + func TestOpenBaoManager_ProxyConfiguration(t *testing.T) { 570 + tests := []struct { 571 + name string 572 + proxyAddr string 573 + description string 574 + }{ 575 + { 576 + name: "default_localhost", 577 + proxyAddr: "http://127.0.0.1:8200", 578 + description: "Should connect to default localhost proxy", 579 + }, 580 + { 581 + name: "custom_host", 582 + proxyAddr: "http://bao-proxy:8200", 583 + description: "Should connect to custom proxy host", 584 + }, 585 + { 586 + name: "https_proxy", 587 + proxyAddr: "https://127.0.0.1:8200", 588 + description: "Should connect to HTTPS proxy", 589 + }, 590 + } 591 + 592 + for _, tt := range tests { 593 + t.Run(tt.name, func(t *testing.T) { 594 + t.Log("Testing scenario:", tt.description) 595 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 596 + 597 + // All these will fail because no real proxy is running 598 + // but we can test that the configuration is properly accepted 599 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 600 + assert.Error(t, err) // Expected because no real proxy 601 + assert.Nil(t, manager) 602 + assert.Contains(t, err.Error(), "failed to connect to bao proxy") 603 + }) 604 + } 605 + }
+22
spindle/secrets/policy.hcl
··· 1 + # Allow full access to the spindle KV mount 2 + path "spindle/*" { 3 + capabilities = ["create", "read", "update", "delete", "list"] 4 + } 5 + 6 + path "spindle/data/*" { 7 + capabilities = ["create", "read", "update", "delete"] 8 + } 9 + 10 + path "spindle/metadata/*" { 11 + capabilities = ["list", "read", "delete"] 12 + } 13 + 14 + # Allow listing mounts (for connection testing) 15 + path "sys/mounts" { 16 + capabilities = ["read"] 17 + } 18 + 19 + # Allow token self-lookup (for health checks) 20 + path "auth/token/lookup-self" { 21 + capabilities = ["read"] 22 + }
+172
spindle/secrets/sqlite.go
··· 1 + // an sqlite3 backed secret manager 2 + package secrets 3 + 4 + import ( 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "time" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 12 + 13 + type SqliteManager struct { 14 + db *sql.DB 15 + tableName string 16 + } 17 + 18 + type SqliteManagerOpt func(*SqliteManager) 19 + 20 + func WithTableName(name string) SqliteManagerOpt { 21 + return func(s *SqliteManager) { 22 + s.tableName = name 23 + } 24 + } 25 + 26 + func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 + db, err := sql.Open("sqlite3", dbPath) 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 + } 31 + 32 + manager := &SqliteManager{ 33 + db: db, 34 + tableName: "secrets", 35 + } 36 + 37 + for _, o := range opts { 38 + o(manager) 39 + } 40 + 41 + if err := manager.init(); err != nil { 42 + return nil, err 43 + } 44 + 45 + return manager, nil 46 + } 47 + 48 + // creates a table and sets up the schema, migrations if any can go here 49 + func (s *SqliteManager) init() error { 50 + createTable := 51 + `create table if not exists ` + s.tableName + `( 52 + id integer primary key autoincrement, 53 + repo text not null, 54 + key text not null, 55 + value text not null, 56 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 57 + created_by text not null, 58 + 59 + unique(repo, key) 60 + );` 61 + _, err := s.db.Exec(createTable) 62 + return err 63 + } 64 + 65 + func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 66 + query := fmt.Sprintf(` 67 + insert or ignore into %s (repo, key, value, created_by) 68 + values (?, ?, ?, ?); 69 + `, s.tableName) 70 + 71 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy) 72 + if err != nil { 73 + return err 74 + } 75 + 76 + num, err := res.RowsAffected() 77 + if err != nil { 78 + return err 79 + } 80 + 81 + if num == 0 { 82 + return ErrKeyAlreadyPresent 83 + } 84 + 85 + return nil 86 + } 87 + 88 + func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 89 + query := fmt.Sprintf(` 90 + delete from %s where repo = ? and key = ?; 91 + `, s.tableName) 92 + 93 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key) 94 + if err != nil { 95 + return err 96 + } 97 + 98 + num, err := res.RowsAffected() 99 + if err != nil { 100 + return err 101 + } 102 + 103 + if num == 0 { 104 + return ErrKeyNotFound 105 + } 106 + 107 + return nil 108 + } 109 + 110 + func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) { 111 + query := fmt.Sprintf(` 112 + select repo, key, created_at, created_by from %s where repo = ?; 113 + `, s.tableName) 114 + 115 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 116 + if err != nil { 117 + return nil, err 118 + } 119 + 120 + var ls []LockedSecret 121 + for rows.Next() { 122 + var l LockedSecret 123 + var createdAt string 124 + if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil { 125 + return nil, err 126 + } 127 + 128 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 129 + l.CreatedAt = t 130 + } 131 + 132 + ls = append(ls, l) 133 + } 134 + 135 + if err = rows.Err(); err != nil { 136 + return nil, err 137 + } 138 + 139 + return ls, nil 140 + } 141 + 142 + func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) { 143 + query := fmt.Sprintf(` 144 + select repo, key, value, created_at, created_by from %s where repo = ?; 145 + `, s.tableName) 146 + 147 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 148 + if err != nil { 149 + return nil, err 150 + } 151 + 152 + var ls []UnlockedSecret 153 + for rows.Next() { 154 + var l UnlockedSecret 155 + var createdAt string 156 + if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil { 157 + return nil, err 158 + } 159 + 160 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 161 + l.CreatedAt = t 162 + } 163 + 164 + ls = append(ls, l) 165 + } 166 + 167 + if err = rows.Err(); err != nil { 168 + return nil, err 169 + } 170 + 171 + return ls, nil 172 + }
+590
spindle/secrets/sqlite_test.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "github.com/alecthomas/assert/v2" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + func createInMemoryDB(t *testing.T) *SqliteManager { 13 + t.Helper() 14 + manager, err := NewSQLiteManager(":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory manager: %v", err) 17 + } 18 + return manager 19 + } 20 + 21 + func createTestSecret(repo, key, value, createdBy string) UnlockedSecret { 22 + return UnlockedSecret{ 23 + Key: key, 24 + Value: value, 25 + Repo: DidSlashRepo(repo), 26 + CreatedAt: time.Now(), 27 + CreatedBy: syntax.DID(createdBy), 28 + } 29 + } 30 + 31 + // ensure that interface is satisfied 32 + func TestManagerInterface(t *testing.T) { 33 + var _ Manager = (*SqliteManager)(nil) 34 + } 35 + 36 + func TestNewSQLiteManager(t *testing.T) { 37 + tests := []struct { 38 + name string 39 + dbPath string 40 + opts []SqliteManagerOpt 41 + expectError bool 42 + expectTable string 43 + }{ 44 + { 45 + name: "default table name", 46 + dbPath: ":memory:", 47 + opts: nil, 48 + expectError: false, 49 + expectTable: "secrets", 50 + }, 51 + { 52 + name: "custom table name", 53 + dbPath: ":memory:", 54 + opts: []SqliteManagerOpt{WithTableName("custom_secrets")}, 55 + expectError: false, 56 + expectTable: "custom_secrets", 57 + }, 58 + { 59 + name: "invalid database path", 60 + dbPath: "/invalid/path/to/database.db", 61 + opts: nil, 62 + expectError: true, 63 + expectTable: "", 64 + }, 65 + } 66 + 67 + for _, tt := range tests { 68 + t.Run(tt.name, func(t *testing.T) { 69 + manager, err := NewSQLiteManager(tt.dbPath, tt.opts...) 70 + if tt.expectError { 71 + if err == nil { 72 + t.Error("Expected error but got none") 73 + } 74 + return 75 + } 76 + 77 + if err != nil { 78 + t.Fatalf("Unexpected error: %v", err) 79 + } 80 + defer manager.db.Close() 81 + 82 + if manager.tableName != tt.expectTable { 83 + t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName) 84 + } 85 + }) 86 + } 87 + } 88 + 89 + func TestSqliteManager_AddSecret(t *testing.T) { 90 + tests := []struct { 91 + name string 92 + secrets []UnlockedSecret 93 + expectError []error 94 + }{ 95 + { 96 + name: "add single secret", 97 + secrets: []UnlockedSecret{ 98 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 99 + }, 100 + expectError: []error{nil}, 101 + }, 102 + { 103 + name: "add multiple unique secrets", 104 + secrets: []UnlockedSecret{ 105 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 106 + createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"), 107 + createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"), 108 + }, 109 + expectError: []error{nil, nil, nil}, 110 + }, 111 + { 112 + name: "add duplicate secret", 113 + secrets: []UnlockedSecret{ 114 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 115 + createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"), 116 + }, 117 + expectError: []error{nil, ErrKeyAlreadyPresent}, 118 + }, 119 + } 120 + 121 + for _, tt := range tests { 122 + t.Run(tt.name, func(t *testing.T) { 123 + manager := createInMemoryDB(t) 124 + defer manager.db.Close() 125 + 126 + for i, secret := range tt.secrets { 127 + err := manager.AddSecret(context.Background(), secret) 128 + if err != tt.expectError[i] { 129 + t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err) 130 + } 131 + } 132 + }) 133 + } 134 + } 135 + 136 + func TestSqliteManager_RemoveSecret(t *testing.T) { 137 + tests := []struct { 138 + name string 139 + setupSecrets []UnlockedSecret 140 + removeSecret Secret[any] 141 + expectError error 142 + }{ 143 + { 144 + name: "remove existing secret", 145 + setupSecrets: []UnlockedSecret{ 146 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 147 + }, 148 + removeSecret: Secret[any]{ 149 + Key: "api_key", 150 + Repo: DidSlashRepo("did:plc:foo/repo"), 151 + }, 152 + expectError: nil, 153 + }, 154 + { 155 + name: "remove non-existent secret", 156 + setupSecrets: []UnlockedSecret{ 157 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 158 + }, 159 + removeSecret: Secret[any]{ 160 + Key: "non_existent_key", 161 + Repo: DidSlashRepo("did:plc:foo/repo"), 162 + }, 163 + expectError: ErrKeyNotFound, 164 + }, 165 + { 166 + name: "remove from empty database", 167 + setupSecrets: []UnlockedSecret{}, 168 + removeSecret: Secret[any]{ 169 + Key: "any_key", 170 + Repo: DidSlashRepo("did:plc:foo/repo"), 171 + }, 172 + expectError: ErrKeyNotFound, 173 + }, 174 + { 175 + name: "remove secret from wrong repo", 176 + setupSecrets: []UnlockedSecret{ 177 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 178 + }, 179 + removeSecret: Secret[any]{ 180 + Key: "api_key", 181 + Repo: DidSlashRepo("other.com/repo"), 182 + }, 183 + expectError: ErrKeyNotFound, 184 + }, 185 + } 186 + 187 + for _, tt := range tests { 188 + t.Run(tt.name, func(t *testing.T) { 189 + manager := createInMemoryDB(t) 190 + defer manager.db.Close() 191 + 192 + // Setup secrets 193 + for _, secret := range tt.setupSecrets { 194 + if err := manager.AddSecret(context.Background(), secret); err != nil { 195 + t.Fatalf("Failed to setup secret: %v", err) 196 + } 197 + } 198 + 199 + // Test removal 200 + err := manager.RemoveSecret(context.Background(), tt.removeSecret) 201 + if err != tt.expectError { 202 + t.Errorf("Expected error %v, got %v", tt.expectError, err) 203 + } 204 + }) 205 + } 206 + } 207 + 208 + func TestSqliteManager_GetSecretsLocked(t *testing.T) { 209 + tests := []struct { 210 + name string 211 + setupSecrets []UnlockedSecret 212 + queryRepo DidSlashRepo 213 + expectedCount int 214 + expectedKeys []string 215 + expectError bool 216 + }{ 217 + { 218 + name: "get secrets for repo with multiple secrets", 219 + setupSecrets: []UnlockedSecret{ 220 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 221 + createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"), 222 + createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"), 223 + }, 224 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 225 + expectedCount: 2, 226 + expectedKeys: []string{"key1", "key2"}, 227 + expectError: false, 228 + }, 229 + { 230 + name: "get secrets for repo with single secret", 231 + setupSecrets: []UnlockedSecret{ 232 + createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"), 233 + createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"), 234 + }, 235 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 236 + expectedCount: 1, 237 + expectedKeys: []string{"single_key"}, 238 + expectError: false, 239 + }, 240 + { 241 + name: "get secrets for non-existent repo", 242 + setupSecrets: []UnlockedSecret{ 243 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 244 + }, 245 + queryRepo: DidSlashRepo("nonexistent.com/repo"), 246 + expectedCount: 0, 247 + expectedKeys: []string{}, 248 + expectError: false, 249 + }, 250 + { 251 + name: "get secrets from empty database", 252 + setupSecrets: []UnlockedSecret{}, 253 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 254 + expectedCount: 0, 255 + expectedKeys: []string{}, 256 + expectError: false, 257 + }, 258 + } 259 + 260 + for _, tt := range tests { 261 + t.Run(tt.name, func(t *testing.T) { 262 + manager := createInMemoryDB(t) 263 + defer manager.db.Close() 264 + 265 + // Setup secrets 266 + for _, secret := range tt.setupSecrets { 267 + if err := manager.AddSecret(context.Background(), secret); err != nil { 268 + t.Fatalf("Failed to setup secret: %v", err) 269 + } 270 + } 271 + 272 + // Test getting locked secrets 273 + lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo) 274 + if tt.expectError && err == nil { 275 + t.Error("Expected error but got none") 276 + return 277 + } 278 + if !tt.expectError && err != nil { 279 + t.Fatalf("Unexpected error: %v", err) 280 + } 281 + 282 + if len(lockedSecrets) != tt.expectedCount { 283 + t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets)) 284 + } 285 + 286 + // Verify keys and that values are not present (locked) 287 + foundKeys := make(map[string]bool) 288 + for _, ls := range lockedSecrets { 289 + foundKeys[ls.Key] = true 290 + if ls.Repo != tt.queryRepo { 291 + t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo) 292 + } 293 + if ls.CreatedBy == "" { 294 + t.Error("Expected CreatedBy to be present") 295 + } 296 + if ls.CreatedAt.IsZero() { 297 + t.Error("Expected CreatedAt to be set") 298 + } 299 + } 300 + 301 + for _, expectedKey := range tt.expectedKeys { 302 + if !foundKeys[expectedKey] { 303 + t.Errorf("Expected key %s not found", expectedKey) 304 + } 305 + } 306 + }) 307 + } 308 + } 309 + 310 + func TestSqliteManager_GetSecretsUnlocked(t *testing.T) { 311 + tests := []struct { 312 + name string 313 + setupSecrets []UnlockedSecret 314 + queryRepo DidSlashRepo 315 + expectedCount int 316 + expectedSecrets map[string]string // key -> value 317 + expectError bool 318 + }{ 319 + { 320 + name: "get unlocked secrets for repo with multiple secrets", 321 + setupSecrets: []UnlockedSecret{ 322 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 323 + createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"), 324 + createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"), 325 + }, 326 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 327 + expectedCount: 2, 328 + expectedSecrets: map[string]string{ 329 + "key1": "value1", 330 + "key2": "value2", 331 + }, 332 + expectError: false, 333 + }, 334 + { 335 + name: "get unlocked secrets for repo with single secret", 336 + setupSecrets: []UnlockedSecret{ 337 + createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"), 338 + createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"), 339 + }, 340 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 341 + expectedCount: 1, 342 + expectedSecrets: map[string]string{ 343 + "single_key": "single_value", 344 + }, 345 + expectError: false, 346 + }, 347 + { 348 + name: "get unlocked secrets for non-existent repo", 349 + setupSecrets: []UnlockedSecret{ 350 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 351 + }, 352 + queryRepo: DidSlashRepo("nonexistent.com/repo"), 353 + expectedCount: 0, 354 + expectedSecrets: map[string]string{}, 355 + expectError: false, 356 + }, 357 + { 358 + name: "get unlocked secrets from empty database", 359 + setupSecrets: []UnlockedSecret{}, 360 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 361 + expectedCount: 0, 362 + expectedSecrets: map[string]string{}, 363 + expectError: false, 364 + }, 365 + } 366 + 367 + for _, tt := range tests { 368 + t.Run(tt.name, func(t *testing.T) { 369 + manager := createInMemoryDB(t) 370 + defer manager.db.Close() 371 + 372 + // Setup secrets 373 + for _, secret := range tt.setupSecrets { 374 + if err := manager.AddSecret(context.Background(), secret); err != nil { 375 + t.Fatalf("Failed to setup secret: %v", err) 376 + } 377 + } 378 + 379 + // Test getting unlocked secrets 380 + unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo) 381 + if tt.expectError && err == nil { 382 + t.Error("Expected error but got none") 383 + return 384 + } 385 + if !tt.expectError && err != nil { 386 + t.Fatalf("Unexpected error: %v", err) 387 + } 388 + 389 + if len(unlockedSecrets) != tt.expectedCount { 390 + t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets)) 391 + } 392 + 393 + // Verify keys, values, and metadata 394 + for _, us := range unlockedSecrets { 395 + expectedValue, exists := tt.expectedSecrets[us.Key] 396 + if !exists { 397 + t.Errorf("Unexpected key: %s", us.Key) 398 + continue 399 + } 400 + if us.Value != expectedValue { 401 + t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value) 402 + } 403 + if us.Repo != tt.queryRepo { 404 + t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo) 405 + } 406 + if us.CreatedBy == "" { 407 + t.Error("Expected CreatedBy to be present") 408 + } 409 + if us.CreatedAt.IsZero() { 410 + t.Error("Expected CreatedAt to be set") 411 + } 412 + } 413 + }) 414 + } 415 + } 416 + 417 + // Test that demonstrates interface usage with table-driven tests 418 + func TestManagerInterface_Usage(t *testing.T) { 419 + tests := []struct { 420 + name string 421 + operations []func(Manager) error 422 + expectError bool 423 + }{ 424 + { 425 + name: "successful workflow", 426 + operations: []func(Manager) error{ 427 + func(m Manager) error { 428 + secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user") 429 + return m.AddSecret(context.Background(), secret) 430 + }, 431 + func(m Manager) error { 432 + _, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo")) 433 + return err 434 + }, 435 + func(m Manager) error { 436 + _, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo")) 437 + return err 438 + }, 439 + func(m Manager) error { 440 + secret := Secret[any]{ 441 + Key: "test_key", 442 + Repo: DidSlashRepo("interface.test/repo"), 443 + } 444 + return m.RemoveSecret(context.Background(), secret) 445 + }, 446 + }, 447 + expectError: false, 448 + }, 449 + { 450 + name: "error on duplicate key", 451 + operations: []func(Manager) error{ 452 + func(m Manager) error { 453 + secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user") 454 + return m.AddSecret(context.Background(), secret) 455 + }, 456 + func(m Manager) error { 457 + secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user") 458 + return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent 459 + }, 460 + }, 461 + expectError: true, 462 + }, 463 + } 464 + 465 + for _, tt := range tests { 466 + t.Run(tt.name, func(t *testing.T) { 467 + var manager Manager = createInMemoryDB(t) 468 + defer func() { 469 + if sqliteManager, ok := manager.(*SqliteManager); ok { 470 + sqliteManager.db.Close() 471 + } 472 + }() 473 + 474 + var finalErr error 475 + for i, operation := range tt.operations { 476 + if err := operation(manager); err != nil { 477 + finalErr = err 478 + t.Logf("Operation %d returned error: %v", i, err) 479 + } 480 + } 481 + 482 + if tt.expectError && finalErr == nil { 483 + t.Error("Expected error but got none") 484 + } 485 + if !tt.expectError && finalErr != nil { 486 + t.Errorf("Unexpected error: %v", finalErr) 487 + } 488 + }) 489 + } 490 + } 491 + 492 + // Integration test with table-driven scenarios 493 + func TestSqliteManager_Integration(t *testing.T) { 494 + tests := []struct { 495 + name string 496 + scenario func(*testing.T, *SqliteManager) 497 + }{ 498 + { 499 + name: "multi-repo secret management", 500 + scenario: func(t *testing.T, manager *SqliteManager) { 501 + repo1 := DidSlashRepo("example1.com/repo") 502 + repo2 := DidSlashRepo("example2.com/repo") 503 + 504 + secrets := []UnlockedSecret{ 505 + createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"), 506 + createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"), 507 + createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"), 508 + } 509 + 510 + // Add all secrets 511 + for _, secret := range secrets { 512 + if err := manager.AddSecret(context.Background(), secret); err != nil { 513 + t.Fatalf("Failed to add secret %s: %v", secret.Key, err) 514 + } 515 + } 516 + 517 + // Verify counts 518 + locked1, _ := manager.GetSecretsLocked(context.Background(), repo1) 519 + locked2, _ := manager.GetSecretsLocked(context.Background(), repo2) 520 + 521 + if len(locked1) != 2 { 522 + t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1)) 523 + } 524 + if len(locked2) != 1 { 525 + t.Errorf("Expected 1 secret for repo2, got %d", len(locked2)) 526 + } 527 + 528 + // Remove and verify 529 + secretToRemove := Secret[any]{Key: "db_password", Repo: repo1} 530 + if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil { 531 + t.Fatalf("Failed to remove secret: %v", err) 532 + } 533 + 534 + locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1) 535 + if len(locked1After) != 1 { 536 + t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After)) 537 + } 538 + if locked1After[0].Key != "api_key" { 539 + t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key) 540 + } 541 + }, 542 + }, 543 + { 544 + name: "empty database operations", 545 + scenario: func(t *testing.T, manager *SqliteManager) { 546 + repo := DidSlashRepo("empty.test/repo") 547 + 548 + // Operations on empty database should not error 549 + locked, err := manager.GetSecretsLocked(context.Background(), repo) 550 + if err != nil { 551 + t.Errorf("GetSecretsLocked on empty DB failed: %v", err) 552 + } 553 + if len(locked) != 0 { 554 + t.Errorf("Expected 0 secrets, got %d", len(locked)) 555 + } 556 + 557 + unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo) 558 + if err != nil { 559 + t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err) 560 + } 561 + if len(unlocked) != 0 { 562 + t.Errorf("Expected 0 secrets, got %d", len(unlocked)) 563 + } 564 + 565 + // Remove from empty should return ErrKeyNotFound 566 + nonExistent := Secret[any]{Key: "none", Repo: repo} 567 + err = manager.RemoveSecret(context.Background(), nonExistent) 568 + if err != ErrKeyNotFound { 569 + t.Errorf("Expected ErrKeyNotFound, got %v", err) 570 + } 571 + }, 572 + }, 573 + } 574 + 575 + for _, tt := range tests { 576 + t.Run(tt.name, func(t *testing.T) { 577 + manager := createInMemoryDB(t) 578 + defer manager.db.Close() 579 + tt.scenario(t, manager) 580 + }) 581 + } 582 + } 583 + 584 + func TestSqliteManager_StopperInterface(t *testing.T) { 585 + manager := &SqliteManager{} 586 + 587 + // Verify that SqliteManager does NOT implement the Stopper interface 588 + _, ok := interface{}(manager).(Stopper) 589 + assert.False(t, ok, "SqliteManager should NOT implement Stopper interface") 590 + }
+81 -42
spindle/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + _ "embed" 5 6 "encoding/json" 6 7 "fmt" 7 8 "log/slog" ··· 11 12 "tangled.sh/tangled.sh/core/api/tangled" 12 13 "tangled.sh/tangled.sh/core/eventconsumer" 13 14 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 + "tangled.sh/tangled.sh/core/idresolver" 14 16 "tangled.sh/tangled.sh/core/jetstream" 15 17 "tangled.sh/tangled.sh/core/log" 16 18 "tangled.sh/tangled.sh/core/notifier" ··· 20 22 "tangled.sh/tangled.sh/core/spindle/engine" 21 23 "tangled.sh/tangled.sh/core/spindle/models" 22 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 + "tangled.sh/tangled.sh/core/spindle/xrpc" 23 27 ) 24 28 29 + //go:embed motd 30 + var motd []byte 31 + 25 32 const ( 26 33 rbacDomain = "thisserver" 27 34 ) 28 35 29 36 type Spindle struct { 30 - jc *jetstream.JetstreamClient 31 - db *db.DB 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 35 - eng *engine.Engine 36 - jq *queue.Queue 37 - cfg *config.Config 38 - ks *eventconsumer.Consumer 37 + jc *jetstream.JetstreamClient 38 + db *db.DB 39 + e *rbac.Enforcer 40 + l *slog.Logger 41 + n *notifier.Notifier 42 + eng *engine.Engine 43 + jq *queue.Queue 44 + cfg *config.Config 45 + ks *eventconsumer.Consumer 46 + res *idresolver.Resolver 47 + vault secrets.Manager 39 48 } 40 49 41 50 func Run(ctx context.Context) error { ··· 59 68 60 69 n := notifier.New() 61 70 62 - eng, err := engine.New(ctx, cfg, d, &n) 71 + var vault secrets.Manager 72 + switch cfg.Server.Secrets.Provider { 73 + case "openbao": 74 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 75 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 76 + } 77 + vault, err = secrets.NewOpenBaoManager( 78 + cfg.Server.Secrets.OpenBao.ProxyAddr, 79 + logger, 80 + secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 81 + ) 82 + if err != nil { 83 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 84 + } 85 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 86 + case "sqlite", "": 87 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 88 + if err != nil { 89 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 90 + } 91 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 92 + default: 93 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 + } 95 + 96 + eng, err := engine.New(ctx, cfg, d, &n, vault) 63 97 if err != nil { 64 98 return err 65 99 } ··· 69 103 collections := []string{ 70 104 tangled.SpindleMemberNSID, 71 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 72 107 } 73 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 109 if err != nil { ··· 76 111 } 77 112 jc.AddDid(cfg.Server.Owner) 78 113 114 + resolver := idresolver.DefaultResolver() 115 + 79 116 spindle := Spindle{ 80 - jc: jc, 81 - e: e, 82 - db: d, 83 - l: logger, 84 - n: &n, 85 - eng: eng, 86 - jq: jq, 87 - cfg: cfg, 117 + jc: jc, 118 + e: e, 119 + db: d, 120 + l: logger, 121 + n: &n, 122 + eng: eng, 123 + jq: jq, 124 + cfg: cfg, 125 + res: resolver, 126 + vault: vault, 88 127 } 89 128 90 129 err = e.AddSpindle(rbacDomain) ··· 100 139 // starts a job queue runner in the background 101 140 jq.Start() 102 141 defer jq.Stop() 142 + 143 + // Stop vault token renewal if it implements Stopper 144 + if stopper, ok := vault.(secrets.Stopper); ok { 145 + defer stopper.Stop() 146 + } 103 147 104 148 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 149 if err != nil { ··· 144 188 mux := chi.NewRouter() 145 189 146 190 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 147 - w.Write([]byte( 148 - ` **** 149 - *** *** 150 - *** ** ****** ** 151 - ** * ***** 152 - * ** ** 153 - * * * *************** 154 - ** ** *# ** 155 - * ** ** *** ** 156 - * * ** ** * ****** 157 - * ** ** * ** * * 158 - ** ** *** ** ** * 159 - ** ** * ** * * 160 - ** **** ** * * 161 - ** *** ** ** ** 162 - *** ** ***** 163 - ******************** 164 - ** 165 - * 166 - #************** 167 - ** 168 - ******** 169 - 170 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`)) 191 + w.Write(motd) 171 192 }) 172 193 mux.HandleFunc("/events", s.Events) 173 194 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 174 195 w.Write([]byte(s.cfg.Server.Owner)) 175 196 }) 176 197 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 198 + 199 + mux.Mount("/xrpc", s.XrpcRouter()) 177 200 return mux 201 + } 202 + 203 + func (s *Spindle) XrpcRouter() http.Handler { 204 + logger := s.l.With("route", "xrpc") 205 + 206 + x := xrpc.Xrpc{ 207 + Logger: logger, 208 + Db: s.db, 209 + Enforcer: s.e, 210 + Engine: s.eng, 211 + Config: s.cfg, 212 + Resolver: s.res, 213 + Vault: s.vault, 214 + } 215 + 216 + return x.Router() 178 217 } 179 218 180 219 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
+91
spindle/xrpc/add_secret.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + ) 17 + 18 + func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger 20 + fail := func(e XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoAddSecret_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(GenericError(err)) 34 + return 35 + } 36 + 37 + if err := secrets.ValidateKey(data.Key); err != nil { 38 + fail(GenericError(err)) 39 + return 40 + } 41 + 42 + // unfortunately we have to resolve repo-at here 43 + repoAt, err := syntax.ParseATURI(data.Repo) 44 + if err != nil { 45 + fail(InvalidRepoError(data.Repo)) 46 + return 47 + } 48 + 49 + // resolve this aturi to extract the repo record 50 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 + if err != nil || ident.Handle.IsInvalidHandle() { 52 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 + return 54 + } 55 + 56 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 + if err != nil { 59 + fail(GenericError(err)) 60 + return 61 + } 62 + 63 + repo := resp.Value.Val.(*tangled.Repo) 64 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 + if err != nil { 66 + fail(GenericError(err)) 67 + return 68 + } 69 + 70 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 + l.Error("insufficent permissions", "did", actorDid.String()) 72 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 + return 74 + } 75 + 76 + secret := secrets.UnlockedSecret{ 77 + Repo: secrets.DidSlashRepo(didPath), 78 + Key: data.Key, 79 + Value: data.Value, 80 + CreatedAt: time.Now(), 81 + CreatedBy: actorDid, 82 + } 83 + err = x.Vault.AddSecret(r.Context(), secret) 84 + if err != nil { 85 + l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 + writeError(w, GenericError(err), http.StatusInternalServerError) 87 + return 88 + } 89 + 90 + w.WriteHeader(http.StatusOK) 91 + }
+91
spindle/xrpc/list_secrets.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + ) 17 + 18 + func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger 20 + fail := func(e XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(MissingActorDidError) 28 + return 29 + } 30 + 31 + repoParam := r.URL.Query().Get("repo") 32 + if repoParam == "" { 33 + fail(GenericError(fmt.Errorf("empty params"))) 34 + return 35 + } 36 + 37 + // unfortunately we have to resolve repo-at here 38 + repoAt, err := syntax.ParseATURI(repoParam) 39 + if err != nil { 40 + fail(InvalidRepoError(repoParam)) 41 + return 42 + } 43 + 44 + // resolve this aturi to extract the repo record 45 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 + if err != nil || ident.Handle.IsInvalidHandle() { 47 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + return 49 + } 50 + 51 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 + if err != nil { 54 + fail(GenericError(err)) 55 + return 56 + } 57 + 58 + repo := resp.Value.Val.(*tangled.Repo) 59 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + if err != nil { 61 + fail(GenericError(err)) 62 + return 63 + } 64 + 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 + l.Error("insufficent permissions", "did", actorDid.String()) 67 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + return 69 + } 70 + 71 + ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 + if err != nil { 73 + l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 + writeError(w, GenericError(err), http.StatusInternalServerError) 75 + return 76 + } 77 + 78 + var out tangled.RepoListSecrets_Output 79 + for _, l := range ls { 80 + out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{ 81 + Repo: repoAt.String(), 82 + Key: l.Key, 83 + CreatedAt: l.CreatedAt.Format(time.RFC3339), 84 + CreatedBy: l.CreatedBy.String(), 85 + }) 86 + } 87 + 88 + w.Header().Set("Content-Type", "application/json") 89 + w.WriteHeader(http.StatusOK) 90 + json.NewEncoder(w).Encode(out) 91 + }
+82
spindle/xrpc/remove_secret.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/secrets" 15 + ) 16 + 17 + func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger 19 + fail := func(e XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoRemoveSecret_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(GenericError(err)) 33 + return 34 + } 35 + 36 + // unfortunately we have to resolve repo-at here 37 + repoAt, err := syntax.ParseATURI(data.Repo) 38 + if err != nil { 39 + fail(InvalidRepoError(data.Repo)) 40 + return 41 + } 42 + 43 + // resolve this aturi to extract the repo record 44 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 + if err != nil || ident.Handle.IsInvalidHandle() { 46 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + return 48 + } 49 + 50 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 + if err != nil { 53 + fail(GenericError(err)) 54 + return 55 + } 56 + 57 + repo := resp.Value.Val.(*tangled.Repo) 58 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 + if err != nil { 60 + fail(GenericError(err)) 61 + return 62 + } 63 + 64 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 + l.Error("insufficent permissions", "did", actorDid.String()) 66 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + return 68 + } 69 + 70 + secret := secrets.Secret[any]{ 71 + Repo: secrets.DidSlashRepo(didPath), 72 + Key: data.Key, 73 + } 74 + err = x.Vault.RemoveSecret(r.Context(), secret) 75 + if err != nil { 76 + l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 + writeError(w, GenericError(err), http.StatusInternalServerError) 78 + return 79 + } 80 + 81 + w.WriteHeader(http.StatusOK) 82 + }
+147
spindle/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + _ "embed" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "strings" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth" 13 + "github.com/go-chi/chi/v5" 14 + 15 + "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/idresolver" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + "tangled.sh/tangled.sh/core/spindle/config" 19 + "tangled.sh/tangled.sh/core/spindle/db" 20 + "tangled.sh/tangled.sh/core/spindle/engine" 21 + "tangled.sh/tangled.sh/core/spindle/secrets" 22 + ) 23 + 24 + const ActorDid string = "ActorDid" 25 + 26 + type Xrpc struct { 27 + Logger *slog.Logger 28 + Db *db.DB 29 + Enforcer *rbac.Enforcer 30 + Engine *engine.Engine 31 + Config *config.Config 32 + Resolver *idresolver.Resolver 33 + Vault secrets.Manager 34 + } 35 + 36 + func (x *Xrpc) Router() http.Handler { 37 + r := chi.NewRouter() 38 + 39 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 + r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 + 43 + return r 44 + } 45 + 46 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 + l := x.Logger.With("url", r.URL) 49 + 50 + token := r.Header.Get("Authorization") 51 + token = strings.TrimPrefix(token, "Bearer ") 52 + 53 + s := auth.ServiceAuthValidator{ 54 + Audience: x.Config.Server.Did().String(), 55 + Dir: x.Resolver.Directory(), 56 + } 57 + 58 + did, err := s.Validate(r.Context(), token, nil) 59 + if err != nil { 60 + l.Error("signature verification failed", "err", err) 61 + writeError(w, AuthError(err), http.StatusForbidden) 62 + return 63 + } 64 + 65 + r = r.WithContext( 66 + context.WithValue(r.Context(), ActorDid, did), 67 + ) 68 + 69 + next.ServeHTTP(w, r) 70 + }) 71 + } 72 + 73 + type XrpcError struct { 74 + Tag string `json:"error"` 75 + Message string `json:"message"` 76 + } 77 + 78 + func NewXrpcError(opts ...ErrOpt) XrpcError { 79 + x := XrpcError{} 80 + for _, o := range opts { 81 + o(&x) 82 + } 83 + 84 + return x 85 + } 86 + 87 + type ErrOpt = func(xerr *XrpcError) 88 + 89 + func WithTag(tag string) ErrOpt { 90 + return func(xerr *XrpcError) { 91 + xerr.Tag = tag 92 + } 93 + } 94 + 95 + func WithMessage[S ~string](s S) ErrOpt { 96 + return func(xerr *XrpcError) { 97 + xerr.Message = string(s) 98 + } 99 + } 100 + 101 + func WithError(e error) ErrOpt { 102 + return func(xerr *XrpcError) { 103 + xerr.Message = e.Error() 104 + } 105 + } 106 + 107 + var MissingActorDidError = NewXrpcError( 108 + WithTag("MissingActorDid"), 109 + WithMessage("actor DID not supplied"), 110 + ) 111 + 112 + var AuthError = func(err error) XrpcError { 113 + return NewXrpcError( 114 + WithTag("Auth"), 115 + WithError(fmt.Errorf("signature verification failed: %w", err)), 116 + ) 117 + } 118 + 119 + var InvalidRepoError = func(r string) XrpcError { 120 + return NewXrpcError( 121 + WithTag("InvalidRepo"), 122 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 + ) 124 + } 125 + 126 + func GenericError(err error) XrpcError { 127 + return NewXrpcError( 128 + WithTag("Generic"), 129 + WithError(err), 130 + ) 131 + } 132 + 133 + var AccessControlError = func(d string) XrpcError { 134 + return NewXrpcError( 135 + WithTag("AccessControl"), 136 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 + ) 138 + } 139 + 140 + // this is slightly different from http_util::write_error to follow the spec: 141 + // 142 + // the json object returned must include an "error" and a "message" 143 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 144 + w.Header().Set("Content-Type", "application/json") 145 + w.WriteHeader(status) 146 + json.NewEncoder(w).Encode(e) 147 + }