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

Compare changes

Choose any two refs to compare.

Changed files
+9375 -2268
.tangled
workflows
api
appview
avatar
src
cmd
docs
guard
hook
idresolver
jetstream
knotserver
lexicons
nix
rbac
spindle
+2 -1
.tangled/workflows/fmt.yml
··· 14 15 - name: "go fmt" 16 command: | 17 - gofmt -l . 18
··· 14 15 - name: "go fmt" 16 command: | 17 + unformatted=$(gofmt -l .) 18 + test -z "$unformatted" || (echo "$unformatted" && exit 1) 19
+430
api/tangled/cbor_gen.go
··· 5854 5855 return nil 5856 } 5857 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5858 if t == nil { 5859 _, err := w.Write(cbg.CborNull) ··· 8225 8226 return nil 8227 }
··· 5854 5855 return nil 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 + } 6055 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 6056 if t == nil { 6057 _, err := w.Write(cbg.CborNull) ··· 8423 8424 return nil 8425 } 8426 + func (t *String) MarshalCBOR(w io.Writer) error { 8427 + if t == nil { 8428 + _, err := w.Write(cbg.CborNull) 8429 + return err 8430 + } 8431 + 8432 + cw := cbg.NewCborWriter(w) 8433 + 8434 + if _, err := cw.Write([]byte{165}); err != nil { 8435 + return err 8436 + } 8437 + 8438 + // t.LexiconTypeID (string) (string) 8439 + if len("$type") > 1000000 { 8440 + return xerrors.Errorf("Value in field \"$type\" was too long") 8441 + } 8442 + 8443 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8444 + return err 8445 + } 8446 + if _, err := cw.WriteString(string("$type")); err != nil { 8447 + return err 8448 + } 8449 + 8450 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 8451 + return err 8452 + } 8453 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 8454 + return err 8455 + } 8456 + 8457 + // t.Contents (string) (string) 8458 + if len("contents") > 1000000 { 8459 + return xerrors.Errorf("Value in field \"contents\" was too long") 8460 + } 8461 + 8462 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 8463 + return err 8464 + } 8465 + if _, err := cw.WriteString(string("contents")); err != nil { 8466 + return err 8467 + } 8468 + 8469 + if len(t.Contents) > 1000000 { 8470 + return xerrors.Errorf("Value in field t.Contents was too long") 8471 + } 8472 + 8473 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 8474 + return err 8475 + } 8476 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 8477 + return err 8478 + } 8479 + 8480 + // t.Filename (string) (string) 8481 + if len("filename") > 1000000 { 8482 + return xerrors.Errorf("Value in field \"filename\" was too long") 8483 + } 8484 + 8485 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 8486 + return err 8487 + } 8488 + if _, err := cw.WriteString(string("filename")); err != nil { 8489 + return err 8490 + } 8491 + 8492 + if len(t.Filename) > 1000000 { 8493 + return xerrors.Errorf("Value in field t.Filename was too long") 8494 + } 8495 + 8496 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 8497 + return err 8498 + } 8499 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 8500 + return err 8501 + } 8502 + 8503 + // t.CreatedAt (string) (string) 8504 + if len("createdAt") > 1000000 { 8505 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8506 + } 8507 + 8508 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8509 + return err 8510 + } 8511 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8512 + return err 8513 + } 8514 + 8515 + if len(t.CreatedAt) > 1000000 { 8516 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8517 + } 8518 + 8519 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8520 + return err 8521 + } 8522 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8523 + return err 8524 + } 8525 + 8526 + // t.Description (string) (string) 8527 + if len("description") > 1000000 { 8528 + return xerrors.Errorf("Value in field \"description\" was too long") 8529 + } 8530 + 8531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 8532 + return err 8533 + } 8534 + if _, err := cw.WriteString(string("description")); err != nil { 8535 + return err 8536 + } 8537 + 8538 + if len(t.Description) > 1000000 { 8539 + return xerrors.Errorf("Value in field t.Description was too long") 8540 + } 8541 + 8542 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 8543 + return err 8544 + } 8545 + if _, err := cw.WriteString(string(t.Description)); err != nil { 8546 + return err 8547 + } 8548 + return nil 8549 + } 8550 + 8551 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 8552 + *t = String{} 8553 + 8554 + cr := cbg.NewCborReader(r) 8555 + 8556 + maj, extra, err := cr.ReadHeader() 8557 + if err != nil { 8558 + return err 8559 + } 8560 + defer func() { 8561 + if err == io.EOF { 8562 + err = io.ErrUnexpectedEOF 8563 + } 8564 + }() 8565 + 8566 + if maj != cbg.MajMap { 8567 + return fmt.Errorf("cbor input should be of type map") 8568 + } 8569 + 8570 + if extra > cbg.MaxLength { 8571 + return fmt.Errorf("String: map struct too large (%d)", extra) 8572 + } 8573 + 8574 + n := extra 8575 + 8576 + nameBuf := make([]byte, 11) 8577 + for i := uint64(0); i < n; i++ { 8578 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8579 + if err != nil { 8580 + return err 8581 + } 8582 + 8583 + if !ok { 8584 + // Field doesn't exist on this type, so ignore it 8585 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8586 + return err 8587 + } 8588 + continue 8589 + } 8590 + 8591 + switch string(nameBuf[:nameLen]) { 8592 + // t.LexiconTypeID (string) (string) 8593 + case "$type": 8594 + 8595 + { 8596 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8597 + if err != nil { 8598 + return err 8599 + } 8600 + 8601 + t.LexiconTypeID = string(sval) 8602 + } 8603 + // t.Contents (string) (string) 8604 + case "contents": 8605 + 8606 + { 8607 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8608 + if err != nil { 8609 + return err 8610 + } 8611 + 8612 + t.Contents = string(sval) 8613 + } 8614 + // t.Filename (string) (string) 8615 + case "filename": 8616 + 8617 + { 8618 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8619 + if err != nil { 8620 + return err 8621 + } 8622 + 8623 + t.Filename = string(sval) 8624 + } 8625 + // t.CreatedAt (string) (string) 8626 + case "createdAt": 8627 + 8628 + { 8629 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8630 + if err != nil { 8631 + return err 8632 + } 8633 + 8634 + t.CreatedAt = string(sval) 8635 + } 8636 + // t.Description (string) (string) 8637 + case "description": 8638 + 8639 + { 8640 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8641 + if err != nil { 8642 + return err 8643 + } 8644 + 8645 + t.Description = string(sval) 8646 + } 8647 + 8648 + default: 8649 + // Field doesn't exist on this type, so ignore it 8650 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8651 + return err 8652 + } 8653 + } 8654 + } 8655 + 8656 + return nil 8657 + }
+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 5 // schema: sh.tangled.repo.issue.state.closed 6 7 - const () 8 9 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
··· 4 5 // schema: sh.tangled.repo.issue.state.closed 6 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 10 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 5 // schema: sh.tangled.repo.issue.state.open 6 7 - const () 8 9 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
··· 4 5 // schema: sh.tangled.repo.issue.state.open 6 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 10 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 5 // schema: sh.tangled.repo.pull.status.closed 6 7 - const () 8 9 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
··· 4 5 // schema: sh.tangled.repo.pull.status.closed 6 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 10 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 5 // schema: sh.tangled.repo.pull.status.merged 6 7 - const () 8 9 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
··· 4 5 // schema: sh.tangled.repo.pull.status.merged 6 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 10 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 5 // schema: sh.tangled.repo.pull.status.open 6 7 - const () 8 9 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
··· 4 5 // schema: sh.tangled.repo.pull.status.open 6 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 10 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+25
api/tangled/tangledstring.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+18 -5
appview/config/config.go
··· 10 ) 11 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"` 18 } 19 20 type OAuthConfig struct { ··· 59 DB int `env:"DB, default=0"` 60 } 61 62 func (cfg RedisConfig) ToURL() string { 63 u := &url.URL{ 64 Scheme: "redis", ··· 84 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 87 } 88 89 func LoadConfig(ctx context.Context) (*Config, error) {
··· 10 ) 11 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"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 } 20 21 type OAuthConfig struct { ··· 60 DB int `env:"DB, default=0"` 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 + 73 func (cfg RedisConfig) ToURL() string { 74 u := &url.URL{ 75 Scheme: "redis", ··· 95 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 96 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 97 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 98 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 99 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 100 } 101 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 + }
+80 -3
appview/db/db.go
··· 355 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 356 357 -- constraints 358 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 359 unique (did, instance, subject) 360 ); 361 ··· 435 bytes integer not null check (bytes >= 0), 436 437 unique(repo_at, ref, language) 438 ); 439 440 create table if not exists migrations ( ··· 579 return nil 580 }) 581 582 return &DB{db}, nil 583 } 584 ··· 654 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 655 if kind == reflect.Slice || kind == reflect.Array { 656 if rv.Len() == 0 { 657 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 658 } 659 660 placeholders := make([]string, rv.Len()) ··· 673 kind := rv.Kind() 674 if kind == reflect.Slice || kind == reflect.Array { 675 if rv.Len() == 0 { 676 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 677 } 678 679 out := make([]any, rv.Len())
··· 355 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 356 357 -- constraints 358 unique (did, instance, subject) 359 ); 360 ··· 434 bytes integer not null check (bytes >= 0), 435 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')) 444 + ); 445 + 446 + create table if not exists strings ( 447 + -- identifiers 448 + did text not null, 449 + rkey text not null, 450 + 451 + -- content 452 + filename text not null, 453 + description text, 454 + content text not null, 455 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 456 + edited text, 457 + 458 + primary key (did, rkey) 459 ); 460 461 create table if not exists migrations ( ··· 600 return nil 601 }) 602 603 + // recreate and add rkey + created columns with default constraint 604 + runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 605 + // create new table 606 + // - repo_at instead of repo integer 607 + // - rkey field 608 + // - created field 609 + _, err := tx.Exec(` 610 + create table collaborators_new ( 611 + -- identifiers for the record 612 + id integer primary key autoincrement, 613 + did text not null, 614 + rkey text, 615 + 616 + -- content 617 + subject_did text not null, 618 + repo_at text not null, 619 + 620 + -- meta 621 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 622 + 623 + -- constraints 624 + foreign key (repo_at) references repos(at_uri) on delete cascade 625 + ) 626 + `) 627 + if err != nil { 628 + return err 629 + } 630 + 631 + // copy data 632 + _, err = tx.Exec(` 633 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 634 + select 635 + c.id, 636 + r.did, 637 + '', 638 + c.did, 639 + r.at_uri 640 + from collaborators c 641 + join repos r on c.repo = r.id 642 + `) 643 + if err != nil { 644 + return err 645 + } 646 + 647 + // drop old table 648 + _, err = tx.Exec(`drop table collaborators`) 649 + if err != nil { 650 + return err 651 + } 652 + 653 + // rename new table 654 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 655 + return err 656 + }) 657 + 658 return &DB{db}, nil 659 } 660 ··· 730 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 731 if kind == reflect.Slice || kind == reflect.Array { 732 if rv.Len() == 0 { 733 + // always false 734 + return "1 = 0" 735 } 736 737 placeholders := make([]string, rv.Len()) ··· 750 kind := rv.Kind() 751 if kind == reflect.Slice || kind == reflect.Array { 752 if rv.Len() == 0 { 753 + return nil 754 } 755 756 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 query := ` 104 select email, did 105 from emails 106 - where 107 - verified = ? 108 and email in (` + strings.Join(placeholders, ",") + `) 109 ` 110 ··· 153 ` 154 var count int 155 err := e.QueryRow(query, did, email).Scan(&count) 156 if err != nil { 157 return false, err 158 }
··· 103 query := ` 104 select email, did 105 from emails 106 + where 107 + verified = ? 108 and email in (` + strings.Join(placeholders, ",") + `) 109 ` 110 ··· 153 ` 154 var count int 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) 170 if err != nil { 171 return false, err 172 }
+2 -2
appview/db/follow.go
··· 12 Rkey string 13 } 14 15 - func AddFollow(e Execer, userDid, subjectDid, rkey string) error { 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 - _, err := e.Exec(query, userDid, subjectDid, rkey) 18 return err 19 } 20
··· 12 Rkey string 13 } 14 15 + func AddFollow(e Execer, follow *Follow) error { 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 + _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 18 return err 19 } 20
+17 -12
appview/db/issues.go
··· 9 ) 10 11 type Issue struct { 12 RepoAt syntax.ATURI 13 OwnerDid string 14 IssueId int ··· 65 66 issue.IssueId = nextId 67 68 - _, err = tx.Exec(` 69 insert into issues (repo_at, owner_did, issue_id, title, body) 70 values (?, ?, ?, ?, ?) 71 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 72 if err != nil { 73 return err 74 } 75 76 if err := tx.Commit(); err != nil { 77 return err ··· 89 var issueAt string 90 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 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 100 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 114 ` 115 with numbered_issue as ( 116 select 117 i.owner_did, 118 i.issue_id, 119 i.created, ··· 132 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 ) 134 select 135 owner_did, 136 issue_id, 137 created, ··· 153 var issue Issue 154 var createdAt string 155 var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 if err != nil { 158 return nil, err 159 } ··· 182 183 rows, err := e.Query( 184 `select 185 i.owner_did, 186 i.repo_at, 187 i.issue_id, ··· 213 var issueCreatedAt, repoCreatedAt string 214 var repo Repo 215 err := rows.Scan( 216 &issue.OwnerDid, 217 &issue.RepoAt, 218 &issue.IssueId, ··· 257 } 258 259 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 = ?` 261 row := e.QueryRow(query, repoAt, issueId) 262 263 var issue Issue 264 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 if err != nil { 267 return nil, err 268 } ··· 277 } 278 279 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 = ?` 281 row := e.QueryRow(query, repoAt, issueId) 282 283 var issue Issue 284 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 286 if err != nil { 287 return nil, nil, err 288 }
··· 9 ) 10 11 type Issue struct { 12 + ID int64 13 RepoAt syntax.ATURI 14 OwnerDid string 15 IssueId int ··· 66 67 issue.IssueId = nextId 68 69 + res, err := tx.Exec(` 70 insert into issues (repo_at, owner_did, issue_id, title, body) 71 values (?, ?, ?, ?, ?) 72 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 73 if err != nil { 74 return err 75 } 76 + 77 + lastID, err := res.LastInsertId() 78 + if err != nil { 79 + return err 80 + } 81 + issue.ID = lastID 82 83 if err := tx.Commit(); err != nil { 84 return err ··· 96 var issueAt string 97 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 98 return issueAt, err 99 } 100 101 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 115 ` 116 with numbered_issue as ( 117 select 118 + i.id, 119 i.owner_did, 120 i.issue_id, 121 i.created, ··· 134 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 135 ) 136 select 137 + id, 138 owner_did, 139 issue_id, 140 created, ··· 156 var issue Issue 157 var createdAt string 158 var metadata IssueMetadata 159 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 if err != nil { 161 return nil, err 162 } ··· 185 186 rows, err := e.Query( 187 `select 188 + i.id, 189 i.owner_did, 190 i.repo_at, 191 i.issue_id, ··· 217 var issueCreatedAt, repoCreatedAt string 218 var repo Repo 219 err := rows.Scan( 220 + &issue.ID, 221 &issue.OwnerDid, 222 &issue.RepoAt, 223 &issue.IssueId, ··· 262 } 263 264 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 265 + query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 row := e.QueryRow(query, repoAt, issueId) 267 268 var issue Issue 269 var createdAt string 270 + err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 if err != nil { 272 return nil, err 273 } ··· 282 } 283 284 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 285 + query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 286 row := e.QueryRow(query, repoAt, issueId) 287 288 var issue Issue 289 var createdAt string 290 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 291 if err != nil { 292 return nil, nil, err 293 }
-34
appview/db/repos.go
··· 550 return &repo, nil 551 } 552 553 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 554 - _, err := e.Exec( 555 - `insert into collaborators (did, repo) 556 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 557 - collaborator, repoOwnerDid, repoName, repoKnot) 558 - return err 559 - } 560 - 561 func UpdateDescription(e Execer, repoAt, newDescription string) error { 562 _, err := e.Exec( 563 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) ··· 568 _, err := e.Exec( 569 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 570 return err 571 - } 572 - 573 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 574 - rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator) 575 - if err != nil { 576 - return nil, err 577 - } 578 - defer rows.Close() 579 - 580 - var repoIds []int 581 - for rows.Next() { 582 - var id int 583 - err := rows.Scan(&id) 584 - if err != nil { 585 - return nil, err 586 - } 587 - repoIds = append(repoIds, id) 588 - } 589 - if err := rows.Err(); err != nil { 590 - return nil, err 591 - } 592 - if repoIds == nil { 593 - return nil, nil 594 - } 595 - 596 - return GetRepos(e, 0, FilterIn("id", repoIds)) 597 } 598 599 type RepoStats struct {
··· 550 return &repo, nil 551 } 552 553 func UpdateDescription(e Execer, repoAt, newDescription string) error { 554 _, err := e.Exec( 555 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) ··· 560 _, err := e.Exec( 561 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 562 return err 563 } 564 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 return nil 34 } 35 36 - func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error { 37 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 38 - _, err := e.Exec(query, starredByDid, repoAt, rkey) 39 return err 40 } 41
··· 33 return nil 34 } 35 36 + func AddStar(e Execer, star *Star) error { 37 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 38 + _, err := e.Exec( 39 + query, 40 + star.StarredByDid, 41 + star.RepoAt.String(), 42 + star.Rkey, 43 + ) 44 return err 45 } 46
+251
appview/db/strings.go
···
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if !strings.Contains(s.Filename, ".") { 54 + err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 + } 56 + 57 + if strings.HasSuffix(s.Filename, ".") { 58 + err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 + } 60 + 61 + if utf8.RuneCountInString(s.Filename) > 140 { 62 + err = errors.Join(err, fmt.Errorf("filename too long")) 63 + } 64 + 65 + if utf8.RuneCountInString(s.Description) > 280 { 66 + err = errors.Join(err, fmt.Errorf("description too long")) 67 + } 68 + 69 + if len(s.Contents) == 0 { 70 + err = errors.Join(err, fmt.Errorf("contents is empty")) 71 + } 72 + 73 + return err 74 + } 75 + 76 + func (s *String) AsRecord() tangled.String { 77 + return tangled.String{ 78 + Filename: s.Filename, 79 + Description: s.Description, 80 + Contents: s.Contents, 81 + CreatedAt: s.Created.Format(time.RFC3339), 82 + } 83 + } 84 + 85 + func StringFromRecord(did, rkey string, record tangled.String) String { 86 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 87 + if err != nil { 88 + created = time.Now() 89 + } 90 + return String{ 91 + Did: syntax.DID(did), 92 + Rkey: rkey, 93 + Filename: record.Filename, 94 + Description: record.Description, 95 + Contents: record.Contents, 96 + Created: created, 97 + } 98 + } 99 + 100 + func AddString(e Execer, s String) error { 101 + _, err := e.Exec( 102 + `insert into strings ( 103 + did, 104 + rkey, 105 + filename, 106 + description, 107 + content, 108 + created, 109 + edited 110 + ) 111 + values (?, ?, ?, ?, ?, ?, null) 112 + on conflict(did, rkey) do update set 113 + filename = excluded.filename, 114 + description = excluded.description, 115 + content = excluded.content, 116 + edited = case 117 + when 118 + strings.content != excluded.content 119 + or strings.filename != excluded.filename 120 + or strings.description != excluded.description then ? 121 + else strings.edited 122 + end`, 123 + s.Did, 124 + s.Rkey, 125 + s.Filename, 126 + s.Description, 127 + s.Contents, 128 + s.Created.Format(time.RFC3339), 129 + time.Now().Format(time.RFC3339), 130 + ) 131 + return err 132 + } 133 + 134 + func GetStrings(e Execer, filters ...filter) ([]String, error) { 135 + var all []String 136 + 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 142 + } 143 + 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 147 + } 148 + 149 + query := fmt.Sprintf(`select 150 + did, 151 + rkey, 152 + filename, 153 + description, 154 + content, 155 + created, 156 + edited 157 + from strings %s`, 158 + whereClause, 159 + ) 160 + 161 + rows, err := e.Query(query, args...) 162 + 163 + if err != nil { 164 + return nil, err 165 + } 166 + defer rows.Close() 167 + 168 + for rows.Next() { 169 + var s String 170 + var createdAt string 171 + var editedAt sql.NullString 172 + 173 + if err := rows.Scan( 174 + &s.Did, 175 + &s.Rkey, 176 + &s.Filename, 177 + &s.Description, 178 + &s.Contents, 179 + &createdAt, 180 + &editedAt, 181 + ); err != nil { 182 + return nil, err 183 + } 184 + 185 + s.Created, err = time.Parse(time.RFC3339, createdAt) 186 + if err != nil { 187 + s.Created = time.Now() 188 + } 189 + 190 + if editedAt.Valid { 191 + e, err := time.Parse(time.RFC3339, editedAt.String) 192 + if err != nil { 193 + e = time.Now() 194 + } 195 + s.Edited = &e 196 + } 197 + 198 + all = append(all, s) 199 + } 200 + 201 + if err := rows.Err(); err != nil { 202 + return nil, err 203 + } 204 + 205 + return all, nil 206 + } 207 + 208 + func DeleteString(e Execer, filters ...filter) error { 209 + var conditions []string 210 + var args []any 211 + for _, filter := range filters { 212 + conditions = append(conditions, filter.Condition()) 213 + args = append(args, filter.Arg()...) 214 + } 215 + 216 + whereClause := "" 217 + if conditions != nil { 218 + whereClause = " where " + strings.Join(conditions, " and ") 219 + } 220 + 221 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 222 + 223 + _, err := e.Exec(query, args...) 224 + return err 225 + } 226 + 227 + func countLines(r io.Reader) (int, error) { 228 + buf := make([]byte, 32*1024) 229 + bufLen := 0 230 + count := 0 231 + nl := []byte{'\n'} 232 + 233 + for { 234 + c, err := r.Read(buf) 235 + if c > 0 { 236 + bufLen += c 237 + } 238 + count += bytes.Count(buf[:c], nl) 239 + 240 + switch { 241 + case err == io.EOF: 242 + /* handle last line not having a newline at the end */ 243 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 244 + count++ 245 + } 246 + return count, nil 247 + case err != nil: 248 + return 0, err 249 + } 250 + } 251 + }
+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 - }
···
+77 -4
appview/ingester.go
··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 "tangled.sh/tangled.sh/core/appview/spindleverify" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) 21 ··· 64 err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 err = i.ingestSpindle(e) 67 } 68 l = i.Logger.With("nsid", e.Commit.Collection) 69 } ··· 100 l.Error("invalid record", "err", err) 101 return err 102 } 103 - err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey) 104 case models.CommitOperationDelete: 105 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 106 } ··· 129 return err 130 } 131 132 - subjectDid := record.Subject 133 - err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey) 134 case models.CommitOperationDelete: 135 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 136 } ··· 503 i.Enforcer.E.LoadPolicy() 504 }() 505 506 err = db.DeleteSpindle( 507 tx, 508 db.FilterEq("owner", did), ··· 532 533 return nil 534 }
··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) 21 ··· 64 err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 err = i.ingestSpindle(e) 67 + case tangled.StringNSID: 68 + err = i.ingestString(e) 69 } 70 l = i.Logger.With("nsid", e.Commit.Collection) 71 } ··· 102 l.Error("invalid record", "err", err) 103 return err 104 } 105 + err = db.AddStar(i.Db, &db.Star{ 106 + StarredByDid: did, 107 + RepoAt: subjectUri, 108 + Rkey: e.Commit.RKey, 109 + }) 110 case models.CommitOperationDelete: 111 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 112 } ··· 135 return err 136 } 137 138 + err = db.AddFollow(i.Db, &db.Follow{ 139 + UserDid: did, 140 + SubjectDid: record.Subject, 141 + Rkey: e.Commit.RKey, 142 + }) 143 case models.CommitOperationDelete: 144 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 145 } ··· 512 i.Enforcer.E.LoadPolicy() 513 }() 514 515 + // remove spindle members first 516 + err = db.RemoveSpindleMember( 517 + tx, 518 + db.FilterEq("owner", did), 519 + db.FilterEq("instance", instance), 520 + ) 521 + if err != nil { 522 + return err 523 + } 524 + 525 err = db.DeleteSpindle( 526 tx, 527 db.FilterEq("owner", did), ··· 551 552 return nil 553 } 554 + 555 + func (i *Ingester) ingestString(e *models.Event) error { 556 + did := e.Did 557 + rkey := e.Commit.RKey 558 + 559 + var err error 560 + 561 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 562 + l.Info("ingesting record") 563 + 564 + ddb, ok := i.Db.Execer.(*db.DB) 565 + if !ok { 566 + return fmt.Errorf("failed to index string record, invalid db cast") 567 + } 568 + 569 + switch e.Commit.Operation { 570 + case models.CommitOperationCreate, models.CommitOperationUpdate: 571 + raw := json.RawMessage(e.Commit.Record) 572 + record := tangled.String{} 573 + err = json.Unmarshal(raw, &record) 574 + if err != nil { 575 + l.Error("invalid record", "err", err) 576 + return err 577 + } 578 + 579 + string := db.StringFromRecord(did, rkey, record) 580 + 581 + if err = string.Validate(); err != nil { 582 + l.Error("invalid record", "err", err) 583 + return err 584 + } 585 + 586 + if err = db.AddString(ddb, string); err != nil { 587 + l.Error("failed to add string", "err", err) 588 + return err 589 + } 590 + 591 + return nil 592 + 593 + case models.CommitOperationDelete: 594 + if err := db.DeleteString( 595 + ddb, 596 + db.FilterEq("did", did), 597 + db.FilterEq("rkey", rkey), 598 + ); err != nil { 599 + l.Error("failed to delete", "err", err) 600 + return fmt.Errorf("failed to delete string record: %w", err) 601 + } 602 + 603 + return nil 604 + } 605 + 606 + return nil 607 + }
+18 -33
appview/issues/issues.go
··· 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 - "github.com/posthog/posthog-go" 18 19 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/idresolver" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 ) 29 30 type Issues struct { ··· 34 idResolver *idresolver.Resolver 35 db *db.DB 36 config *config.Config 37 - posthog posthog.Client 38 } 39 40 func New( ··· 44 idResolver *idresolver.Resolver, 45 db *db.DB, 46 config *config.Config, 47 - posthog posthog.Client, 48 ) *Issues { 49 return &Issues{ 50 oauth: oauth, ··· 53 idResolver: idResolver, 54 db: db, 55 config: config, 56 - posthog: posthog, 57 } 58 } 59 ··· 120 DidHandleMap: didHandleMap, 121 122 OrderedReactionKinds: db.OrderedReactionKinds, 123 - Reactions: reactionCountMap, 124 - UserReacted: userReactions, 125 }) 126 127 } ··· 171 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 Collection: tangled.RepoIssueStateNSID, 173 Repo: user.Did, 174 - Rkey: appview.TID(), 175 Record: &lexutil.LexiconTypeDecoder{ 176 Val: &tangled.RepoIssueState{ 177 Issue: issue.IssueAt, ··· 275 } 276 277 commentId := mathrand.IntN(1000000) 278 - rkey := appview.TID() 279 280 err := db.NewIssueComment(rp.db, &db.Comment{ 281 OwnerDid: user.Did, ··· 703 return 704 } 705 706 - err = db.NewIssue(tx, &db.Issue{ 707 RepoAt: f.RepoAt, 708 Title: title, 709 Body: body, 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 } 717 - 718 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 719 if err != nil { 720 - log.Println("failed to get issue id", err) 721 rp.pages.Notice(w, "issues", "Failed to create issue.") 722 return 723 } ··· 732 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 733 Collection: tangled.RepoIssueNSID, 734 Repo: user.Did, 735 - Rkey: appview.TID(), 736 Record: &lexutil.LexiconTypeDecoder{ 737 Val: &tangled.RepoIssue{ 738 Repo: atUri, 739 Title: title, 740 Body: &body, 741 Owner: user.Did, 742 - IssueId: int64(issueId), 743 }, 744 }, 745 }) ··· 749 return 750 } 751 752 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 753 if err != nil { 754 log.Println("failed to set issue at", err) 755 rp.pages.Notice(w, "issues", "Failed to create issue.") 756 return 757 } 758 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 - } 769 770 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 771 return 772 } 773 }
··· 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/config" 20 "tangled.sh/tangled.sh/core/appview/db" 21 + "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 "tangled.sh/tangled.sh/core/appview/pagination" 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/idresolver" 27 + "tangled.sh/tangled.sh/core/tid" 28 ) 29 30 type Issues struct { ··· 34 idResolver *idresolver.Resolver 35 db *db.DB 36 config *config.Config 37 + notifier notify.Notifier 38 } 39 40 func New( ··· 44 idResolver *idresolver.Resolver, 45 db *db.DB, 46 config *config.Config, 47 + notifier notify.Notifier, 48 ) *Issues { 49 return &Issues{ 50 oauth: oauth, ··· 53 idResolver: idResolver, 54 db: db, 55 config: config, 56 + notifier: notifier, 57 } 58 } 59 ··· 120 DidHandleMap: didHandleMap, 121 122 OrderedReactionKinds: db.OrderedReactionKinds, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 125 }) 126 127 } ··· 171 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 Collection: tangled.RepoIssueStateNSID, 173 Repo: user.Did, 174 + Rkey: tid.TID(), 175 Record: &lexutil.LexiconTypeDecoder{ 176 Val: &tangled.RepoIssueState{ 177 Issue: issue.IssueAt, ··· 275 } 276 277 commentId := mathrand.IntN(1000000) 278 + rkey := tid.TID() 279 280 err := db.NewIssueComment(rp.db, &db.Comment{ 281 OwnerDid: user.Did, ··· 703 return 704 } 705 706 + issue := &db.Issue{ 707 RepoAt: f.RepoAt, 708 Title: title, 709 Body: body, 710 OwnerDid: user.Did, 711 } 712 + err = db.NewIssue(tx, issue) 713 if err != nil { 714 + log.Println("failed to create issue", err) 715 rp.pages.Notice(w, "issues", "Failed to create issue.") 716 return 717 } ··· 726 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 Collection: tangled.RepoIssueNSID, 728 Repo: user.Did, 729 + Rkey: tid.TID(), 730 Record: &lexutil.LexiconTypeDecoder{ 731 Val: &tangled.RepoIssue{ 732 Repo: atUri, 733 Title: title, 734 Body: &body, 735 Owner: user.Did, 736 + IssueId: int64(issue.IssueId), 737 }, 738 }, 739 }) ··· 743 return 744 } 745 746 + err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 747 if err != nil { 748 log.Println("failed to set issue at", err) 749 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 return 751 } 752 753 + rp.notifier.NewIssue(r.Context(), issue) 754 755 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 756 return 757 } 758 }
+3 -4
appview/knots/knots.go
··· 13 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/appview" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 "tangled.sh/tangled.sh/core/appview/middleware" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/eventconsumer" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 378 } 379 380 w.Write([]byte(strings.Join(memberDids, "\n"))) 381 - return 382 } 383 384 // add member to domain, requires auth and requires invite access ··· 436 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 437 Collection: tangled.KnotMemberNSID, 438 Repo: currentUser.Did, 439 - Rkey: appview.TID(), 440 Record: &lexutil.LexiconTypeDecoder{ 441 Val: &tangled.KnotMember{ 442 Subject: subjectIdentity.DID.String(),
··· 13 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/middleware" 19 "tangled.sh/tangled.sh/core/appview/oauth" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/eventconsumer" 22 + "tangled.sh/tangled.sh/core/idresolver" 23 "tangled.sh/tangled.sh/core/knotclient" 24 "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/tid" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 378 } 379 380 w.Write([]byte(strings.Join(memberDids, "\n"))) 381 } 382 383 // add member to domain, requires auth and requires invite access ··· 435 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 Collection: tangled.KnotMemberNSID, 437 Repo: currentUser.Did, 438 + Rkey: tid.TID(), 439 Record: &lexutil.LexiconTypeDecoder{ 440 Val: &tangled.KnotMember{ 441 Subject: subjectIdentity.DID.String(),
+3 -11
appview/middleware/middleware.go
··· 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/pagination" 20 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) 23 ··· 167 } 168 } 169 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 func (mw Middleware) ResolveIdent() middlewareFunc { 181 excluded := []string{"favicon.ico"} 182 ··· 187 next.ServeHTTP(w, req) 188 return 189 } 190 191 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 if err != nil {
··· 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/appview/db" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/pagination" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) 23 ··· 167 } 168 } 169 170 func (mw Middleware) ResolveIdent() middlewareFunc { 171 excluded := []string{"favicon.ico"} 172 ··· 177 next.ServeHTTP(w, req) 178 return 179 } 180 + 181 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 182 183 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 184 if err != nil {
+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 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 "tangled.sh/tangled.sh/core/appview/middleware" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 )
··· 16 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 "tangled.sh/tangled.sh/core/appview/middleware" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/oauth/client" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 )
+73
appview/oauth/oauth.go
··· 7 "net/url" 8 "time" 9 10 "github.com/gorilla/sessions" 11 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 204 }) 205 206 return xrpcClient, nil 207 } 208 209 type ClientMetadata struct {
··· 7 "net/url" 8 "time" 9 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 oauth "tangled.sh/icyphox.sh/atproto-oauth" 13 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 205 }) 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 280 } 281 282 type ClientMetadata struct {
+1 -1
appview/pages/funcmap.go
··· 191 if v.Len() == 0 { 192 return nil 193 } 194 - return v.Slice(0, min(n, v.Len()-1)).Interface() 195 }, 196 197 "markdown": func(text string) template.HTML {
··· 191 if v.Len() == 0 { 192 return nil 193 } 194 + return v.Slice(0, min(n, v.Len())).Interface() 195 }, 196 197 "markdown": func(text string) template.HTML {
+2 -2
appview/pages/markup/camo.go
··· 9 "github.com/yuin/goldmark/ast" 10 ) 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 13 h := hmac.New(sha256.New, []byte(secret)) 14 h.Write([]byte(imageURL)) 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 } 25 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 } 29 30 return dst
··· 9 "github.com/yuin/goldmark/ast" 10 ) 11 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 h := hmac.New(sha256.New, []byte(secret)) 14 h.Write([]byte(imageURL)) 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 } 25 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 } 29 30 return dst
+156 -10
appview/pages/pages.go
··· 16 "strings" 17 "sync" 18 19 "tangled.sh/tangled.sh/core/appview/commitverify" 20 "tangled.sh/tangled.sh/core/appview/config" 21 "tangled.sh/tangled.sh/core/appview/db" ··· 30 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 31 "github.com/alecthomas/chroma/v2/lexers" 32 "github.com/alecthomas/chroma/v2/styles" 33 "github.com/bluesky-social/indigo/atproto/syntax" 34 "github.com/go-git/go-git/v5/plumbing" 35 "github.com/go-git/go-git/v5/plumbing/object" 36 - "github.com/microcosm-cc/bluemonday" 37 ) 38 39 //go:embed templates/* static ··· 262 return p.executePlain("user/login", w, params) 263 } 264 265 type TimelineParams struct { 266 LoggedInUser *oauth.User 267 Timeline []db.TimelineEvent ··· 391 UserDid string 392 UserHandle string 393 FollowStatus db.FollowStatus 394 - AvatarUri string 395 Followers int 396 Following int 397 ··· 448 return p.executePlain("user/fragments/editPins", w, params) 449 } 450 451 - type RepoActionsFragmentParams struct { 452 IsStarred bool 453 RepoAt syntax.ATURI 454 Stats db.RepoStats 455 } 456 457 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 458 - return p.executePlain("repo/fragments/repoActions", w, params) 459 } 460 461 type RepoDescriptionParams struct { ··· 502 ext := filepath.Ext(params.ReadmeFileName) 503 switch ext { 504 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 505 htmlString = p.rctx.RenderMarkdown(params.Readme) 506 params.Raw = false 507 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 508 default: 509 - htmlString = string(params.Readme) 510 params.Raw = true 511 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 512 } 513 } 514 ··· 555 RepoInfo repoinfo.RepoInfo 556 Active string 557 BreadCrumbs [][]string 558 - BaseTreeLink string 559 - BaseBlobLink string 560 types.RepoTreeResponse 561 } 562 ··· 626 LoggedInUser *oauth.User 627 RepoInfo repoinfo.RepoInfo 628 Active string 629 BreadCrumbs [][]string 630 ShowRendered bool 631 RenderToggle bool ··· 693 Branches []types.Branch 694 Spindles []string 695 CurrentSpindle string 696 // TODO: use repoinfo.roles 697 IsCollaboratorInviteAllowed bool 698 } ··· 702 return p.executeRepo("repo/settings", w, params) 703 } 704 705 type RepoIssuesParams struct { 706 LoggedInUser *oauth.User 707 RepoInfo repoinfo.RepoInfo ··· 813 DidHandleMap map[string]string 814 FilteringBy db.PullState 815 Stacks map[string]db.Stack 816 } 817 818 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1069 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1070 params.Active = "pipelines" 1071 return p.executeRepo("repo/pipelines/workflow", w, params) 1072 } 1073 1074 func (p *Pages) Static() http.Handler {
··· 16 "strings" 17 "sync" 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview/commitverify" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" ··· 31 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 32 "github.com/alecthomas/chroma/v2/lexers" 33 "github.com/alecthomas/chroma/v2/styles" 34 + "github.com/bluesky-social/indigo/atproto/identity" 35 "github.com/bluesky-social/indigo/atproto/syntax" 36 "github.com/go-git/go-git/v5/plumbing" 37 "github.com/go-git/go-git/v5/plumbing/object" 38 ) 39 40 //go:embed templates/* static ··· 263 return p.executePlain("user/login", w, params) 264 } 265 266 + func (p *Pages) Signup(w io.Writer) error { 267 + return p.executePlain("user/signup", w, nil) 268 + } 269 + 270 + func (p *Pages) CompleteSignup(w io.Writer) error { 271 + return p.executePlain("user/completeSignup", w, nil) 272 + } 273 + 274 + type TermsOfServiceParams struct { 275 + LoggedInUser *oauth.User 276 + } 277 + 278 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 279 + return p.execute("legal/terms", w, params) 280 + } 281 + 282 + type PrivacyPolicyParams struct { 283 + LoggedInUser *oauth.User 284 + } 285 + 286 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 287 + return p.execute("legal/privacy", w, params) 288 + } 289 + 290 type TimelineParams struct { 291 LoggedInUser *oauth.User 292 Timeline []db.TimelineEvent ··· 416 UserDid string 417 UserHandle string 418 FollowStatus db.FollowStatus 419 Followers int 420 Following int 421 ··· 472 return p.executePlain("user/fragments/editPins", w, params) 473 } 474 475 + type RepoStarFragmentParams struct { 476 IsStarred bool 477 RepoAt syntax.ATURI 478 Stats db.RepoStats 479 } 480 481 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 482 + return p.executePlain("repo/fragments/repoStar", w, params) 483 } 484 485 type RepoDescriptionParams struct { ··· 526 ext := filepath.Ext(params.ReadmeFileName) 527 switch ext { 528 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 529 + htmlString = p.rctx.Sanitize(htmlString) 530 htmlString = p.rctx.RenderMarkdown(params.Readme) 531 params.Raw = false 532 + params.HTMLReadme = template.HTML(htmlString) 533 default: 534 params.Raw = true 535 } 536 } 537 ··· 578 RepoInfo repoinfo.RepoInfo 579 Active string 580 BreadCrumbs [][]string 581 + TreePath string 582 types.RepoTreeResponse 583 } 584 ··· 648 LoggedInUser *oauth.User 649 RepoInfo repoinfo.RepoInfo 650 Active string 651 + Unsupported bool 652 + IsImage bool 653 + IsVideo bool 654 + ContentSrc string 655 BreadCrumbs [][]string 656 ShowRendered bool 657 RenderToggle bool ··· 719 Branches []types.Branch 720 Spindles []string 721 CurrentSpindle string 722 + Secrets []*tangled.RepoListSecrets_Secret 723 + 724 // TODO: use repoinfo.roles 725 IsCollaboratorInviteAllowed bool 726 } ··· 730 return p.executeRepo("repo/settings", w, params) 731 } 732 733 + type RepoGeneralSettingsParams struct { 734 + LoggedInUser *oauth.User 735 + RepoInfo repoinfo.RepoInfo 736 + Active string 737 + Tabs []map[string]any 738 + Tab string 739 + Branches []types.Branch 740 + } 741 + 742 + func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 743 + params.Active = "settings" 744 + return p.executeRepo("repo/settings/general", w, params) 745 + } 746 + 747 + type RepoAccessSettingsParams struct { 748 + LoggedInUser *oauth.User 749 + RepoInfo repoinfo.RepoInfo 750 + Active string 751 + Tabs []map[string]any 752 + Tab string 753 + Collaborators []Collaborator 754 + } 755 + 756 + func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 757 + params.Active = "settings" 758 + return p.executeRepo("repo/settings/access", w, params) 759 + } 760 + 761 + type RepoPipelineSettingsParams struct { 762 + LoggedInUser *oauth.User 763 + RepoInfo repoinfo.RepoInfo 764 + Active string 765 + Tabs []map[string]any 766 + Tab string 767 + Spindles []string 768 + CurrentSpindle string 769 + Secrets []map[string]any 770 + } 771 + 772 + func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 773 + params.Active = "settings" 774 + return p.executeRepo("repo/settings/pipelines", w, params) 775 + } 776 + 777 type RepoIssuesParams struct { 778 LoggedInUser *oauth.User 779 RepoInfo repoinfo.RepoInfo ··· 885 DidHandleMap map[string]string 886 FilteringBy db.PullState 887 Stacks map[string]db.Stack 888 + Pipelines map[string]db.Pipeline 889 } 890 891 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1142 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1143 params.Active = "pipelines" 1144 return p.executeRepo("repo/pipelines/workflow", w, params) 1145 + } 1146 + 1147 + type PutStringParams struct { 1148 + LoggedInUser *oauth.User 1149 + Action string 1150 + 1151 + // this is supplied in the case of editing an existing string 1152 + String db.String 1153 + } 1154 + 1155 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1156 + return p.execute("strings/put", w, params) 1157 + } 1158 + 1159 + type StringsDashboardParams struct { 1160 + LoggedInUser *oauth.User 1161 + Card ProfileCard 1162 + Strings []db.String 1163 + } 1164 + 1165 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1166 + return p.execute("strings/dashboard", w, params) 1167 + } 1168 + 1169 + type SingleStringParams struct { 1170 + LoggedInUser *oauth.User 1171 + ShowRendered bool 1172 + RenderToggle bool 1173 + RenderedContents template.HTML 1174 + String db.String 1175 + Stats db.StringStats 1176 + Owner identity.Identity 1177 + } 1178 + 1179 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1180 + var style *chroma.Style = styles.Get("catpuccin-latte") 1181 + 1182 + if params.ShowRendered { 1183 + switch markup.GetFormat(params.String.Filename) { 1184 + case markup.FormatMarkdown: 1185 + p.rctx.RendererType = markup.RendererTypeDefault 1186 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1187 + params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1188 + } 1189 + } 1190 + 1191 + c := params.String.Contents 1192 + formatter := chromahtml.New( 1193 + chromahtml.InlineCode(false), 1194 + chromahtml.WithLineNumbers(true), 1195 + chromahtml.WithLinkableLineNumbers(true, "L"), 1196 + chromahtml.Standalone(false), 1197 + chromahtml.WithClasses(true), 1198 + ) 1199 + 1200 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1201 + if lexer == nil { 1202 + lexer = lexers.Fallback 1203 + } 1204 + 1205 + iterator, err := lexer.Tokenise(nil, c) 1206 + if err != nil { 1207 + return fmt.Errorf("chroma tokenize: %w", err) 1208 + } 1209 + 1210 + var code bytes.Buffer 1211 + err = formatter.Format(&code, style, iterator) 1212 + if err != nil { 1213 + return fmt.Errorf("chroma format: %w", err) 1214 + } 1215 + 1216 + params.String.Contents = code.String() 1217 + return p.execute("strings/string", w, params) 1218 } 1219 1220 func (p *Pages) Static() http.Handler {
+1 -1
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 <div 14 id="add-member-{{ .Id }}" 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 17 {{ block "addKnotMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }}
··· 13 <div 14 id="add-member-{{ .Id }}" 15 popover 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 {{ block "addKnotMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }}
+19 -30
appview/pages/templates/layouts/base.html
··· 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 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" style="z-index: 20;"> 22 - {{ template "layouts/topbar" . }} 23 - </header> 24 - </div> 25 - {{ end }} 26 - </div> 27 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"> 31 <div class="col-span-1 md:col-span-2"> 32 {{ block "contentLeft" . }} {{ end }} 33 </div> ··· 37 <div class="col-span-1 md:col-span-2"> 38 {{ block "contentRight" . }} {{ end }} 39 </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ block "contentAfterLayout" . }} 44 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 45 <div class="col-span-1 md:col-span-2"> 46 {{ block "contentAfterLeft" . }} {{ end }} 47 </div> ··· 51 <div class="col-span-1 md:col-span-2"> 52 {{ block "contentAfterRight" . }} {{ end }} 53 </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> 64 </div> 65 - {{ end }} 66 - </div> 67 68 </body> 69 </html> 70 {{ end }}
··· 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 16 </head> 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 }} 23 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" . }} 27 <div class="col-span-1 md:col-span-2"> 28 {{ block "contentLeft" . }} {{ end }} 29 </div> ··· 33 <div class="col-span-1 md:col-span-2"> 34 {{ block "contentRight" . }} {{ end }} 35 </div> 36 + {{ end }} 37 + 38 + {{ block "contentAfterLayout" . }} 39 <div class="col-span-1 md:col-span-2"> 40 {{ block "contentAfterLeft" . }} {{ end }} 41 </div> ··· 45 <div class="col-span-1 md:col-span-2"> 46 {{ block "contentAfterRight" . }} {{ end }} 47 </div> 48 + {{ end }} 49 </div> 50 + {{ end }} 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 }} 57 </body> 58 </html> 59 {{ end }}
+44 -3
appview/pages/templates/layouts/footer.html
··· 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> 5 </div> 6 </div> 7 {{ end }}
··· 1 {{ define "layouts/footer" }} 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 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 </div> 46 + </div> 47 </div> 48 {{ end }}
+23 -1
appview/pages/templates/layouts/repobase.html
··· 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 </div> 21 22 - {{ template "repo/fragments/repoActions" .RepoInfo }} 23 </div> 24 {{ template "repo/fragments/repoDescription" . }} 25 </section>
··· 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 </div> 21 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> 45 </div> 46 {{ template "repo/fragments/repoDescription" . }} 47 </section>
+27 -17
appview/pages/templates/layouts/topbar.html
··· 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"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 tangled<sub>alpha</sub> 7 </a> 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 23 {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 27 {{ block "dropDown" . }} {{ end }} 28 {{ else }} 29 <a href="/login">login</a> 30 {{ end }} 31 </div> 32 </div> 33 </nav> 34 {{ end }} 35 36 {{ define "dropDown" }} 37 <details class="relative inline-block text-left"> 38 <summary ··· 45 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 > 47 <a href="/{{ $user }}">profile</a> 48 <a href="/knots">knots</a> 49 <a href="/spindles">spindles</a> 50 <a href="/settings">settings</a>
··· 1 {{ define "layouts/topbar" }} 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 tangled<sub>alpha</sub> 7 </a> 8 </div> 9 10 + <div id="right-items" class="flex items-center gap-2"> 11 {{ with .LoggedInUser }} 12 + {{ block "newButton" . }} {{ end }} 13 {{ block "dropDown" . }} {{ end }} 14 {{ else }} 15 <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 20 {{ end }} 21 </div> 22 </div> 23 </nav> 24 {{ end }} 25 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left"> 28 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 + {{ i "plus" "w-4 h-4" }} new 30 + </summary> 31 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 + <a href="/repo/new" class="flex items-center gap-2"> 33 + {{ i "book-plus" "w-4 h-4" }} 34 + new repository 35 + </a> 36 + <a href="/strings/new" class="flex items-center gap-2"> 37 + {{ i "line-squiggle" "w-4 h-4" }} 38 + new string 39 + </a> 40 + </div> 41 + </details> 42 + {{ end }} 43 + 44 {{ define "dropDown" }} 45 <details class="relative inline-block text-left"> 46 <summary ··· 53 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" 54 > 55 <a href="/{{ $user }}">profile</a> 56 + <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 58 <a href="/knots">knots</a> 59 <a href="/spindles">spindles</a> 60 <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 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 11 {{ end }} 12 13 {{ define "repoContent" }} ··· 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 {{ if .RenderToggle }} 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 hx-boost="true" 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 {{ end }} 52 </div> 53 </div> 54 </div> 55 - {{ if .IsBinary }} 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 58 </p> 59 {{ else }} 60 <div class="overflow-auto relative"> 61 {{ if .ShowRendered }}
··· 5 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 + 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 + 11 {{ end }} 12 13 {{ define "repoContent" }} ··· 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 {{ if .RenderToggle }} 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 hx-boost="true" 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 {{ end }} 52 </div> 53 </div> 54 </div> 55 + {{ if and .IsBinary .Unsupported }} 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 + Previews are not supported for this file type. 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> 72 {{ else }} 73 <div class="overflow-auto relative"> 74 {{ if .ShowRendered }}
+20 -14
appview/pages/templates/repo/commit.html
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 - <header style="z-index: 20;"> 84 {{ template "layouts/topbar" . }} 85 </header> 86 {{ end }} 87 88 - {{ define "contentLayout" }} 89 - {{ block "content" . }}{{ end }} 90 - {{ end }} 91 92 - {{ define "contentAfterLayout" }} 93 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 94 - <div class="col-span-1 md:col-span-2"> 95 - {{ block "contentAfterLeft" . }} {{ end }} 96 </div> 97 - <main class="col-span-1 md:col-span-10"> 98 - {{ block "contentAfter" . }}{{ end }} 99 - </main> 100 </div> 101 {{ end }} 102 103 - {{ define "footerLayout" }} 104 - {{ template "layouts/footer" . }} 105 {{ end }} 106 107 {{ define "contentAfter" }} ··· 112 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 113 {{ template "repo/fragments/diffOpts" .DiffOpts }} 114 </div> 115 - <div class="sticky top-0 mt-4"> 116 {{ template "repo/fragments/diffChangedFiles" .Diff }} 117 </div> 118 {{end}}
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 {{ template "layouts/topbar" . }} 85 </header> 86 {{ end }} 87 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 }} 93 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> 102 </div> 103 + {{ end }} 104 </div> 105 {{ end }} 106 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/footer" . }} 110 + </footer> 111 {{ end }} 112 113 {{ define "contentAfter" }} ··· 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 </div> 121 + <div class="sticky top-0 flex-grow max-h-screen"> 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 </div> 124 {{end}}
+22 -14
appview/pages/templates/repo/compare/compare.html
··· 11 {{ end }} 12 13 {{ define "topbarLayout" }} 14 - {{ template "layouts/topbar" . }} 15 {{ end }} 16 17 - {{ define "contentLayout" }} 18 - {{ block "content" . }}{{ end }} 19 - {{ end }} 20 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 </div> 26 - <main class="col-span-1 md:col-span-10"> 27 - {{ block "contentAfter" . }}{{ end }} 28 - </main> 29 </div> 30 {{ end }} 31 32 - {{ define "footerLayout" }} 33 - {{ template "layouts/footer" . }} 34 {{ end }} 35 36 {{ define "contentAfter" }} ··· 41 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 42 {{ template "repo/fragments/diffOpts" .DiffOpts }} 43 </div> 44 - <div class="sticky top-0 mt-4"> 45 {{ template "repo/fragments/diffChangedFiles" .Diff }} 46 </div> 47 {{end}}
··· 11 {{ end }} 12 13 {{ define "topbarLayout" }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 17 {{ end }} 18 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 }} 24 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> 33 </div> 34 + {{ end }} 35 </div> 36 {{ end }} 37 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/footer" . }} 41 + </footer> 42 {{ end }} 43 44 {{ define "contentAfter" }} ··· 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 </div> 52 + <div class="sticky top-0 flex-grow max-h-screen"> 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 </div> 55 {{end}}
+15 -3
appview/pages/templates/repo/empty.html
··· 23 {{ end }} 24 </div> 25 </div> 26 {{ else }} 27 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 28 - This is an empty repository. Push some commits here. 29 - </p> 30 {{ end }} 31 </main> 32 {{ end }}
··· 23 {{ end }} 24 </div> 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> 40 {{ else }} 41 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 42 {{ end }} 43 </main> 44 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 {{ define "repo/fragments/diffChangedFiles" }} 2 {{ $stat := .Stat }} 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"> 5 <div class="diff-stat"> 6 <div class="flex gap-2 items-center"> 7 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
··· 1 {{ define "repo/fragments/diffChangedFiles" }} 2 {{ $stat := .Stat }} 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 min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 <div class="diff-stat"> 6 <div class="flex gap-2 items-center"> 7 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 {{ define "repo/fragments/interdiffFiles" }} 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"> 4 <div class="diff-stat"> 5 <div class="flex gap-2 items-center"> 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
··· 1 {{ define "repo/fragments/interdiffFiles" }} 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 min-h-full text-sm"> 4 <div class="diff-stat"> 5 <div class="flex gap-2 items-center"> 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 }}
+38 -70
appview/pages/templates/repo/index.html
··· 127 {{ end }} 128 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" }} 136 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> 150 - 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> 171 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> 180 {{ end }} 181 182 {{ define "rightInfo" }} ··· 190 {{ define "commitLog" }} 191 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 192 <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> 200 </a> 201 </div> 202 <div class="flex flex-col gap-6"> ··· 298 {{ define "branchList" }} 299 {{ if gt (len .BranchesTrunc) 0 }} 300 <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> 308 </a> 309 <div class="flex flex-col gap-1"> 310 {{ range .BranchesTrunc }} ··· 341 {{ if gt (len .TagsTrunc) 0 }} 342 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 343 <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> 351 </a> 352 </div> 353 <div class="flex flex-col gap-1"> ··· 378 {{ end }} 379 380 {{ define "repoAfter" }} 381 - {{- if .HTMLReadme -}} 382 <section 383 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 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 387 {{ end }}" 388 > 389 <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 390 - {{- .HTMLReadme -}} 391 </pre> 392 {{- else -}} 393 {{ .HTMLReadme }}
··· 127 {{ end }} 128 129 {{ define "fileTree" }} 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" }} 132 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" }} 139 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> 151 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> 160 {{ end }} 161 162 {{ define "rightInfo" }} ··· 170 {{ define "commitLog" }} 171 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 172 <div class="flex justify-between items-center"> 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> 176 </a> 177 </div> 178 <div class="flex flex-col gap-6"> ··· 274 {{ define "branchList" }} 275 {{ if gt (len .BranchesTrunc) 0 }} 276 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 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> 280 </a> 281 <div class="flex flex-col gap-1"> 282 {{ range .BranchesTrunc }} ··· 313 {{ if gt (len .TagsTrunc) 0 }} 314 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 315 <div class="flex justify-between items-center"> 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> 319 </a> 320 </div> 321 <div class="flex flex-col gap-1"> ··· 346 {{ end }} 347 348 {{ define "repoAfter" }} 349 + {{- if or .HTMLReadme .Readme -}} 350 <section 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 }} 352 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 355 {{ end }}" 356 > 357 <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 + {{- .Readme -}} 359 </pre> 360 {{- else -}} 361 {{ .HTMLReadme }}
+2 -4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 {{ with .Comment }} 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 ··· 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 {{ if $isIssueAuthor }} 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 author 14 - </span> 15 {{ end }} 16 17 <span class="before:content-['ยท']"></span> 18 <a 19 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 id="{{ .CommentId }}"> 22 {{ template "repo/fragments/time" .Created }} 23 </a>
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 {{ with .Comment }} 3 <div id="comment-container-{{.CommentId}}"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 ··· 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 {{ if $isIssueAuthor }} 11 <span class="before:content-['ยท']"></span> 12 author 13 {{ end }} 14 15 <span class="before:content-['ยท']"></span> 16 <a 17 href="#{{ .CommentId }}" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 id="{{ .CommentId }}"> 20 {{ template "repo/fragments/time" .Created }} 21 </a>
+7 -8
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 {{ template "user/fragments/picHandleLink" $owner }} 7 8 <span class="before:content-['ยท']"></span> 9 <a 10 href="#{{ .CommentId }}" ··· 18 {{ template "repo/fragments/time" .Created }} 19 {{ end }} 20 </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 30 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 {{ if and $isCommentOwner (not .Deleted) }}
··· 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 {{ template "user/fragments/picHandleLink" $owner }} 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 + 15 <span class="before:content-['ยท']"></span> 16 <a 17 href="#{{ .CommentId }}" ··· 25 {{ template "repo/fragments/time" .Created }} 26 {{ end }} 27 </a> 28 29 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 30 {{ if and $isCommentOwner (not .Deleted) }}
+72 -75
appview/pages/templates/repo/log.html
··· 14 </h2> 15 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> 61 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 }} 69 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> 78 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> 95 96 <!-- mobile view (visible only on small screens) --> 97 <div class="md:hidden">
··· 14 </h2> 15 16 <!-- desktop view (hidden on small screens) --> 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> 59 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 }} 67 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> 76 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> 92 93 <!-- mobile view (visible only on small screens) --> 94 <div class="md:hidden">
+5 -7
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 13 </span> 14 </div> 15 16 - <div class="flex-shrink-0 flex items-center"> 17 {{ $latestRound := .LastRoundNumber }} 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 {{ $commentCount := len $lastSubmission.Comments }} 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> 25 {{ end }} 26 <span> 27 - <div class="inline-flex items-center gap-2"> 28 {{ i "message-square" "w-3 h-3 md:hidden" }} 29 {{ $commentCount }} 30 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 31 </div> 32 </span> 33 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 34 <span> 35 <span class="hidden md:inline">round</span> 36 <span class="font-mono">#{{ $latestRound }}</span>
··· 13 </span> 14 </div> 15 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 {{ $latestRound := .LastRoundNumber }} 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 {{ $commentCount := len $lastSubmission.Comments }} 20 {{ if and $pipeline $pipeline.Id }} 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 {{ end }} 24 <span> 25 + <div class="inline-flex items-center gap-1"> 26 {{ i "message-square" "w-3 h-3 md:hidden" }} 27 {{ $commentCount }} 28 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 29 </div> 30 </span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 32 <span> 33 <span class="hidden md:inline">round</span> 34 <span class="font-mono">#{{ $latestRound }}</span>
+22 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 29 {{ end }} 30 31 {{ define "topbarLayout" }} 32 - {{ template "layouts/topbar" . }} 33 {{ end }} 34 35 - {{ define "contentLayout" }} 36 - {{ block "content" . }}{{ end }} 37 - {{ end }} 38 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 </div> 44 - <main class="col-span-1 md:col-span-10"> 45 - {{ block "contentAfter" . }}{{ end }} 46 - </main> 47 </div> 48 {{ end }} 49 50 - {{ define "footerLayout" }} 51 - {{ template "layouts/footer" . }} 52 {{ end }} 53 54 ··· 60 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 61 {{ template "repo/fragments/diffOpts" .DiffOpts }} 62 </div> 63 - <div class="sticky top-0 mt-4"> 64 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 65 </div> 66 {{end}}
··· 29 {{ end }} 30 31 {{ define "topbarLayout" }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/topbar" . }} 34 + </header> 35 {{ end }} 36 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 }} 42 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> 51 </div> 52 + {{ end }} 53 </div> 54 {{ end }} 55 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/footer" . }} 59 + </footer> 60 {{ end }} 61 62 ··· 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 </div> 71 + <div class="sticky top-0 flex-grow max-h-screen"> 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 </div> 74 {{end}}
+22 -14
appview/pages/templates/repo/pulls/patch.html
··· 35 {{ end }} 36 37 {{ define "topbarLayout" }} 38 - {{ template "layouts/topbar" . }} 39 {{ end }} 40 41 - {{ define "contentLayout" }} 42 - {{ block "content" . }}{{ end }} 43 - {{ end }} 44 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 </div> 50 - <main class="col-span-1 md:col-span-10"> 51 - {{ block "contentAfter" . }}{{ end }} 52 - </main> 53 </div> 54 {{ end }} 55 56 - {{ define "footerLayout" }} 57 - {{ template "layouts/footer" . }} 58 {{ end }} 59 60 {{ define "contentAfter" }} ··· 65 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 66 {{ template "repo/fragments/diffOpts" .DiffOpts }} 67 </div> 68 - <div class="sticky top-0 mt-4"> 69 {{ template "repo/fragments/diffChangedFiles" .Diff }} 70 </div> 71 {{end}}
··· 35 {{ end }} 36 37 {{ define "topbarLayout" }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/topbar" . }} 40 + </header> 41 {{ end }} 42 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 }} 48 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> 57 </div> 58 + {{ end }} 59 </div> 60 {{ end }} 61 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/footer" . }} 65 + </footer> 66 {{ end }} 67 68 {{ define "contentAfter" }} ··· 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 </div> 76 + <div class="sticky top-0 flex-grow max-h-screen"> 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 </div> 79 {{end}}
+43 -52
appview/pages/templates/repo/pulls/pulls.html
··· 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 {{ $owner := index $.DidHandleMap .OwnerDid }} 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 {{ $icon := "ban" }} ··· 83 {{ template "repo/fragments/time" .Created }} 84 </span> 85 86 <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 </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 -}} 103 </span> 104 {{ 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> 125 </div> 126 {{ if .StackId }} 127 {{ $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> 143 {{ end }} 144 </div> 145 {{ end }} ··· 151 {{ $root := index . 1 }} 152 <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 {{ range $pull := $list }} 154 <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 <div class="flex gap-2 items-center px-6"> 156 <div class="flex-grow min-w-0 w-full py-2"> 157 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 158 </div> 159 </div> 160 </a>
··· 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div> 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 {{ $owner := index $.DidHandleMap .OwnerDid }} 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 {{ $icon := "ban" }} ··· 83 {{ template "repo/fragments/time" .Created }} 84 </span> 85 86 + 87 + {{ $latestRound := .LastRoundNumber }} 88 + {{ $lastSubmission := index .Submissions $latestRound }} 89 + 90 <span class="before:content-['ยท']"> 91 + {{ $commentCount := len $lastSubmission.Comments }} 92 + {{ $s := "s" }} 93 + {{ if eq $commentCount 1 }} 94 + {{ $s = "" }} 95 + {{ end }} 96 + 97 + {{ len $lastSubmission.Comments}} comment{{$s}} 98 </span> 99 + 100 + <span class="before:content-['ยท']"> 101 + round 102 + <span class="font-mono"> 103 + #{{ .LastRoundNumber }} 104 + </span> 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 }} 111 {{ end }} 112 + </div> 113 </div> 114 {{ if .StackId }} 115 {{ $otherPulls := index $.Stacks .StackId }} 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 }} 133 {{ end }} 134 </div> 135 {{ end }} ··· 141 {{ $root := index . 1 }} 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"> 143 {{ range $pull := $list }} 144 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 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"> 146 <div class="flex gap-2 items-center px-6"> 147 <div class="flex-grow min-w-0 w-full py-2"> 148 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 149 </div> 150 </div> 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 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 6 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> 24 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 29 > 30 - <label for="collaborator" class="dark:text-white"> 31 - add collaborator 32 - </label> 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> 49 {{ end }} 50 51 <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 54 > 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> 115 </form> 116 - {{ end }} 117 118 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 119 <form 120 hx-confirm="Are you sure you want to delete this repository?" 121 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 122 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> 135 </form> 136 - {{ end }} 137 138 {{ end }}
··· 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 {{ define "repoContent" }} 4 + {{ template "collaboratorSettings" . }} 5 + {{ template "branchSettings" . }} 6 + {{ template "dangerZone" . }} 7 + {{ template "spindleSelector" . }} 8 + {{ template "spindleSecrets" . }} 9 + {{ end }} 10 11 + {{ define "collaboratorSettings" }} 12 + <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 + Collaborators 14 + </header> 15 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" 22 > 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> 31 {{ end }} 32 + </div> 33 34 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 <form 36 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 + class="group" 38 > 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> 53 </form> 54 + {{ end }} 55 + {{ end }} 56 57 + {{ define "dangerZone" }} 58 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 <form 60 hx-confirm="Are you sure you want to delete this repository?" 61 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 class="mt-6" 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> 74 </form> 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" /> 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 }} 168 {{ end }}
+26 -34
appview/pages/templates/repo/tree.html
··· 19 {{define "repoContent"}} 20 <main> 21 <div class="tree"> 22 - {{ $containerstyle := "py-1" }} 23 {{ $linkstyle := "no-underline hover:underline" }} 24 25 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 54 </div> 55 56 {{ 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> 69 </div> 70 - {{ end }} 71 </div> 72 - </div> 73 - {{ end }} 74 - {{ end }} 75 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 }} 91 </div> 92 - </div> 93 {{ end }} 94 - {{ end }} 95 </div> 96 </main> 97 {{end}}
··· 19 {{define "repoContent"}} 20 <main> 21 <div class="tree"> 22 {{ $linkstyle := "no-underline hover:underline" }} 23 24 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 53 </div> 54 55 {{ range .Files }} 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 </div> 70 + </a> 71 </div> 72 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 }} 83 </div> 84 + </div> 85 {{ end }} 86 + 87 </div> 88 </main> 89 {{end}}
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 <div 14 id="add-member-{{ .Instance }}" 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 17 {{ block "addMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }}
··· 13 <div 14 id="add-member-{{ .Instance }}" 15 popover 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 {{ block "addMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }}
+57
appview/pages/templates/strings/dashboard.html
···
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+89
appview/pages/templates/strings/fragments/form.html
···
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename with extension" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 + rows="20" 36 + placeholder="Paste your string here!" 37 + required>{{ .String.Contents }}</textarea> 38 + <div class="flex justify-between items-center"> 39 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 40 + <span id="line-count">0 lines</span> 41 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 42 + <span id="byte-count">0 bytes</span> 43 + </div> 44 + <div id="actions" class="flex gap-2 items-center"> 45 + {{ if eq .Action "edit" }} 46 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 47 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 48 + {{ i "x" "size-4" }} 49 + <span class="hidden md:inline">cancel</span> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </a> 52 + {{ end }} 53 + <button 54 + type="submit" 55 + id="new-button" 56 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 57 + > 58 + <span class="inline-flex items-center gap-2"> 59 + {{ i "arrow-up" "w-4 h-4" }} 60 + publish 61 + </span> 62 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 63 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 64 + </span> 65 + </button> 66 + </div> 67 + </div> 68 + <script> 69 + (function() { 70 + const textarea = document.getElementById('content-textarea'); 71 + const lineCount = document.getElementById('line-count'); 72 + const byteCount = document.getElementById('byte-count'); 73 + function updateStats() { 74 + const content = textarea.value; 75 + const lines = content === '' ? 0 : content.split('\n').length; 76 + const bytes = new TextEncoder().encode(content).length; 77 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 78 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 79 + } 80 + textarea.addEventListener('input', updateStats); 81 + textarea.addEventListener('paste', () => { 82 + setTimeout(updateStats, 0); 83 + }); 84 + updateStats(); 85 + })(); 86 + </script> 87 + <div id="error" class="error dark:text-red-400"></div> 88 + </form> 89 + {{ end }}
+17
appview/pages/templates/strings/put.html
···
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+85
appview/pages/templates/strings/string.html
···
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span class="flex items-center"> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 + {{ end }} 52 + 53 + {{ with .String.Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 + {{ end }} 58 + </span> 59 + </section> 60 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 62 + <span>{{ .String.Filename }}</span> 63 + <div> 64 + <span>{{ .Stats.LineCount }} lines</span> 65 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 66 + <span>{{ byteFmt .Stats.ByteCount }}</span> 67 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 68 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 69 + {{ if .RenderToggle }} 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 72 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 73 + </a> 74 + {{ end }} 75 + </div> 76 + </div> 77 + <div class="overflow-auto relative"> 78 + {{ if .ShowRendered }} 79 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 + {{ else }} 81 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 82 + {{ end }} 83 + </div> 84 + </section> 85 + {{ end }}
+2 -2
appview/pages/templates/timeline.html
··· 34 </p> 35 36 <div class="flex gap-6 items-center"> 37 - <a href="/login" class="no-underline hover:no-underline "> 38 - <button class="btn flex gap-2 px-4 items-center"> 39 join now {{ i "arrow-right" "size-4" }} 40 </button> 41 </a>
··· 34 </p> 35 36 <div class="flex gap-6 items-center"> 37 + <a href="/signup" class="no-underline hover:no-underline "> 38 + <button class="btn-create flex gap-2 px-4 items-center"> 39 join now {{ i "arrow-right" "size-4" }} 40 </button> 41 </a>
+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 flex flex-col gap-4" 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"> 62 + <label for="username">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"> 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 text-base" 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 }}
+1 -3
appview/pages/templates/user/fragments/profileCard.html
··· 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 8 </div> 9 - {{ end }} 10 </div> 11 <div class="col-span-2"> 12 <p title="{{ didOrHandle .UserDid .UserHandle }}"
··· 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 <div class="w-3/4 aspect-square relative"> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 </div> 8 </div> 9 <div class="col-span-2"> 10 <p title="{{ didOrHandle .UserDid .UserHandle }}"
+13 -34
appview/pages/templates/user/login.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="login ยท tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.sh/login" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="login to tangled" 21 - /> 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>login &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"> ··· 51 name="handle" 52 tabindex="1" 53 required 54 /> 55 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 60 </span> 61 </div> 62 63 <button 64 - class="btn w-full my-2 mt-6" 65 type="submit" 66 id="login-button" 67 tabindex="3" ··· 70 </button> 71 </form> 72 <p class="text-sm text-gray-500"> 73 - Join our <a href="https://chat.tangled.sh">Discord</a> or 74 - IRC channel: 75 - <a href="https://web.libera.chat/#tangled" 76 - ><code>#tangled</code> on Libera Chat</a 77 - >. 78 </p> 79 <p id="login-msg" class="error w-full"></p> 80 </main> 81 </body>
··· 3 <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 17 tangled 18 </h1> 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 33 name="handle" 34 tabindex="1" 35 required 36 + placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 + Use your <a href="https://atproto.com">ATProto</a> 40 + handle to log in. If you're unsure, this is likely 41 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span> 43 </div> 44 45 <button 46 + class="btn w-full my-2 mt-6 text-base " 47 type="submit" 48 id="login-button" 49 tabindex="3" ··· 52 </button> 53 </form> 54 <p class="text-sm text-gray-500"> 55 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 56 </p> 57 + 58 <p id="login-msg" class="error w-full"></p> 59 </main> 60 </body>
+3 -3
appview/pages/templates/user/repos.html
··· 8 {{ end }} 9 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"> 13 {{ template "user/fragments/profileCard" .Card }} 14 </div> 15 - <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 16 {{ block "ownRepos" . }}{{ end }} 17 </div> 18 </div>
··· 8 {{ end }} 9 10 {{ define "content" }} 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 {{ template "user/fragments/profileCard" .Card }} 14 </div> 15 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 {{ block "ownRepos" . }}{{ end }} 17 </div> 18 </div>
+53
appview/pages/templates/user/signup.html
···
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+1 -5
appview/pipelines/pipelines.go
··· 11 12 "tangled.sh/tangled.sh/core/appview/config" 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/idresolver" 15 "tangled.sh/tangled.sh/core/appview/oauth" 16 "tangled.sh/tangled.sh/core/appview/pages" 17 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 "tangled.sh/tangled.sh/core/eventconsumer" 19 "tangled.sh/tangled.sh/core/log" 20 "tangled.sh/tangled.sh/core/rbac" 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket" 25 - "github.com/posthog/posthog-go" 26 ) 27 28 type Pipelines struct { ··· 34 spindlestream *eventconsumer.Consumer 35 db *db.DB 36 enforcer *rbac.Enforcer 37 - posthog posthog.Client 38 logger *slog.Logger 39 } 40 ··· 46 idResolver *idresolver.Resolver, 47 db *db.DB, 48 config *config.Config, 49 - posthog posthog.Client, 50 enforcer *rbac.Enforcer, 51 ) *Pipelines { 52 logger := log.New("pipelines") ··· 58 config: config, 59 spindlestream: spindlestream, 60 db: db, 61 - posthog: posthog, 62 enforcer: enforcer, 63 logger: logger, 64 }
··· 11 12 "tangled.sh/tangled.sh/core/appview/config" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/oauth" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 "tangled.sh/tangled.sh/core/appview/reporesolver" 17 "tangled.sh/tangled.sh/core/eventconsumer" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/log" 20 "tangled.sh/tangled.sh/core/rbac" 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket" 25 ) 26 27 type Pipelines struct { ··· 33 spindlestream *eventconsumer.Consumer 34 db *db.DB 35 enforcer *rbac.Enforcer 36 logger *slog.Logger 37 } 38 ··· 44 idResolver *idresolver.Resolver, 45 db *db.DB, 46 config *config.Config, 47 enforcer *rbac.Enforcer, 48 ) *Pipelines { 49 logger := log.New("pipelines") ··· 55 config: config, 56 spindlestream: spindlestream, 57 db: db, 58 enforcer: enforcer, 59 logger: logger, 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 "time" 15 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/types" 27 28 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 "github.com/go-chi/chi/v5" 33 "github.com/google/uuid" 34 - "github.com/posthog/posthog-go" 35 ) 36 37 type Pulls struct { ··· 41 idResolver *idresolver.Resolver 42 db *db.DB 43 config *config.Config 44 - posthog posthog.Client 45 } 46 47 func New( ··· 51 resolver *idresolver.Resolver, 52 db *db.DB, 53 config *config.Config, 54 - posthog posthog.Client, 55 ) *Pulls { 56 return &Pulls{ 57 oauth: oauth, ··· 60 idResolver: resolver, 61 db: db, 62 config: config, 63 - posthog: posthog, 64 } 65 } 66 ··· 555 556 // we want to group all stacked PRs into just one list 557 stacks := make(map[string]db.Stack) 558 n := 0 559 for _, p := range pulls { 560 // this PR is stacked 561 if p.StackId != "" { 562 // we have already seen this PR stack ··· 575 } 576 pulls = pulls[:n] 577 578 identsToResolve := make([]string, len(pulls)) 579 for i, pull := range pulls { 580 identsToResolve[i] = pull.OwnerDid ··· 596 DidHandleMap: didHandleMap, 597 FilteringBy: state, 598 Stacks: stacks, 599 }) 600 - return 601 } 602 603 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 668 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 669 Collection: tangled.RepoPullCommentNSID, 670 Repo: user.Did, 671 - Rkey: appview.TID(), 672 Record: &lexutil.LexiconTypeDecoder{ 673 Val: &tangled.RepoPullComment{ 674 Repo: &atUri, ··· 685 return 686 } 687 688 - // Create the pull comment in the database with the commentAt field 689 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 690 OwnerDid: user.Did, 691 RepoAt: f.RepoAt.String(), 692 PullId: pull.PullId, 693 Body: body, 694 CommentAt: atResp.Uri, 695 SubmissionId: pull.Submissions[roundNumber].ID, 696 - }) 697 if err != nil { 698 log.Println("failed to create pull comment", err) 699 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 707 return 708 } 709 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 - } 720 721 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 722 return ··· 1045 body = formatPatches[0].Body 1046 } 1047 1048 - rkey := appview.TID() 1049 initialSubmission := db.PullSubmission{ 1050 Patch: patch, 1051 SourceRev: sourceRev, 1052 } 1053 - err = db.NewPull(tx, &db.Pull{ 1054 Title: title, 1055 Body: body, 1056 TargetBranch: targetBranch, ··· 1061 &initialSubmission, 1062 }, 1063 PullSource: pullSource, 1064 - }) 1065 if err != nil { 1066 log.Println("failed to create pull request", err) 1067 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1101 return 1102 } 1103 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 - } 1114 1115 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1116 } ··· 1673 } 1674 1675 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1676 - return 1677 } 1678 1679 func (s *Pulls) resubmitStackedPullHelper( ··· 1917 } 1918 1919 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1920 - return 1921 } 1922 1923 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2041 2042 // auth filter: only owner or collaborators can close 2043 roles := f.RolesInRepo(user) 2044 isCollaborator := roles.IsCollaborator() 2045 isPullAuthor := user.Did == pull.OwnerDid 2046 - isCloseAllowed := isCollaborator || isPullAuthor 2047 if !isCloseAllowed { 2048 log.Println("failed to close pull") 2049 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2087 } 2088 2089 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2090 - return 2091 } 2092 2093 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2109 2110 // auth filter: only owner or collaborators can close 2111 roles := f.RolesInRepo(user) 2112 isCollaborator := roles.IsCollaborator() 2113 isPullAuthor := user.Did == pull.OwnerDid 2114 - isCloseAllowed := isCollaborator || isPullAuthor 2115 if !isCloseAllowed { 2116 log.Println("failed to close pull") 2117 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2155 } 2156 2157 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2158 - return 2159 } 2160 2161 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2181 2182 title := fp.Title 2183 body := fp.Body 2184 - rkey := appview.TID() 2185 2186 initialSubmission := db.PullSubmission{ 2187 Patch: fp.Raw,
··· 14 "time" 15 16 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/appview/config" 18 "tangled.sh/tangled.sh/core/appview/db" 19 + "tangled.sh/tangled.sh/core/appview/notify" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 "github.com/go-chi/chi/v5" 34 "github.com/google/uuid" 35 ) 36 37 type Pulls struct { ··· 41 idResolver *idresolver.Resolver 42 db *db.DB 43 config *config.Config 44 + notifier notify.Notifier 45 } 46 47 func New( ··· 51 resolver *idresolver.Resolver, 52 db *db.DB, 53 config *config.Config, 54 + notifier notify.Notifier, 55 ) *Pulls { 56 return &Pulls{ 57 oauth: oauth, ··· 60 idResolver: resolver, 61 db: db, 62 config: config, 63 + notifier: notifier, 64 } 65 } 66 ··· 555 556 // we want to group all stacked PRs into just one list 557 stacks := make(map[string]db.Stack) 558 + var shas []string 559 n := 0 560 for _, p := range pulls { 561 + // store the sha for later 562 + shas = append(shas, p.LatestSha()) 563 // this PR is stacked 564 if p.StackId != "" { 565 // we have already seen this PR stack ··· 578 } 579 pulls = pulls[:n] 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 + 598 identsToResolve := make([]string, len(pulls)) 599 for i, pull := range pulls { 600 identsToResolve[i] = pull.OwnerDid ··· 616 DidHandleMap: didHandleMap, 617 FilteringBy: state, 618 Stacks: stacks, 619 + Pipelines: m, 620 }) 621 } 622 623 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 689 Collection: tangled.RepoPullCommentNSID, 690 Repo: user.Did, 691 + Rkey: tid.TID(), 692 Record: &lexutil.LexiconTypeDecoder{ 693 Val: &tangled.RepoPullComment{ 694 Repo: &atUri, ··· 705 return 706 } 707 708 + comment := &db.PullComment{ 709 OwnerDid: user.Did, 710 RepoAt: f.RepoAt.String(), 711 PullId: pull.PullId, 712 Body: body, 713 CommentAt: atResp.Uri, 714 SubmissionId: pull.Submissions[roundNumber].ID, 715 + } 716 + 717 + // Create the pull comment in the database with the commentAt field 718 + commentId, err := db.NewPullComment(tx, comment) 719 if err != nil { 720 log.Println("failed to create pull comment", err) 721 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 729 return 730 } 731 732 + s.notifier.NewPullComment(r.Context(), comment) 733 734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 735 return ··· 1058 body = formatPatches[0].Body 1059 } 1060 1061 + rkey := tid.TID() 1062 initialSubmission := db.PullSubmission{ 1063 Patch: patch, 1064 SourceRev: sourceRev, 1065 } 1066 + pull := &db.Pull{ 1067 Title: title, 1068 Body: body, 1069 TargetBranch: targetBranch, ··· 1074 &initialSubmission, 1075 }, 1076 PullSource: pullSource, 1077 + } 1078 + err = db.NewPull(tx, pull) 1079 if err != nil { 1080 log.Println("failed to create pull request", err) 1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1115 return 1116 } 1117 1118 + s.notifier.NewPull(r.Context(), pull) 1119 1120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1121 } ··· 1678 } 1679 1680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1681 } 1682 1683 func (s *Pulls) resubmitStackedPullHelper( ··· 1921 } 1922 1923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1924 } 1925 1926 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2044 2045 // auth filter: only owner or collaborators can close 2046 roles := f.RolesInRepo(user) 2047 + isOwner := roles.IsOwner() 2048 isCollaborator := roles.IsCollaborator() 2049 isPullAuthor := user.Did == pull.OwnerDid 2050 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2051 if !isCloseAllowed { 2052 log.Println("failed to close pull") 2053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2091 } 2092 2093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2094 } 2095 2096 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2112 2113 // auth filter: only owner or collaborators can close 2114 roles := f.RolesInRepo(user) 2115 + isOwner := roles.IsOwner() 2116 isCollaborator := roles.IsCollaborator() 2117 isPullAuthor := user.Did == pull.OwnerDid 2118 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2119 if !isCloseAllowed { 2120 log.Println("failed to close pull") 2121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2159 } 2160 2161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2162 } 2163 2164 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2184 2185 title := fp.Title 2186 body := fp.Body 2187 + rkey := tid.TID() 2188 2189 initialSubmission := db.PullSubmission{ 2190 Patch: fp.Raw,
+2
appview/pulls/router.go
··· 44 r.Get("/", s.ResubmitPull) 45 r.Post("/", s.ResubmitPull) 46 }) 47 r.Post("/close", s.ClosePull) 48 r.Post("/reopen", s.ReopenPull) 49 // collaborators only
··· 44 r.Get("/", s.ResubmitPull) 45 r.Post("/", s.ResubmitPull) 46 }) 47 + // permissions here require us to know pull author 48 + // it is handled within the route 49 r.Post("/close", s.ClosePull) 50 r.Post("/reopen", s.ReopenPull) 51 // collaborators only
+2 -2
appview/repo/artifact.go
··· 14 "github.com/go-git/go-git/v5/plumbing" 15 "github.com/ipfs/go-cid" 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 "tangled.sh/tangled.sh/core/appview/db" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 "tangled.sh/tangled.sh/core/knotclient" 22 "tangled.sh/tangled.sh/core/types" 23 ) 24 ··· 64 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 67 - rkey := appview.TID() 68 createdAt := time.Now() 69 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
··· 14 "github.com/go-git/go-git/v5/plumbing" 15 "github.com/ipfs/go-cid" 16 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 "tangled.sh/tangled.sh/core/knotclient" 21 + "tangled.sh/tangled.sh/core/tid" 22 "tangled.sh/tangled.sh/core/types" 23 ) 24 ··· 64 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 67 + rkey := tid.TID() 68 createdAt := time.Now() 69 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+2
appview/repo/index.go
··· 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 } 60 61 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 62 if a.Name == result.Ref { 63 return -1
··· 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 } 60 61 + sortFiles(result.Files) 62 + 63 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 64 if a.Name == result.Ref { 65 return -1
+413 -124
appview/repo/repo.go
··· 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "net/url" 13 - "path" 14 "slices" 15 - "sort" 16 "strconv" 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 "tangled.sh/tangled.sh/core/appview/commitverify" 23 "tangled.sh/tangled.sh/core/appview/config" 24 "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/idresolver" 26 "tangled.sh/tangled.sh/core/appview/oauth" 27 "tangled.sh/tangled.sh/core/appview/pages" 28 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 "tangled.sh/tangled.sh/core/eventconsumer" 31 "tangled.sh/tangled.sh/core/knotclient" 32 "tangled.sh/tangled.sh/core/patchutil" 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/types" 35 36 securejoin "github.com/cyphar/filepath-securejoin" 37 "github.com/go-chi/chi/v5" 38 "github.com/go-git/go-git/v5/plumbing" 39 - "github.com/posthog/posthog-go" 40 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 lexutil "github.com/bluesky-social/indigo/lex/util" 43 ) 44 ··· 51 spindlestream *eventconsumer.Consumer 52 db *db.DB 53 enforcer *rbac.Enforcer 54 - posthog posthog.Client 55 } 56 57 func New( ··· 62 idResolver *idresolver.Resolver, 63 db *db.DB, 64 config *config.Config, 65 - posthog posthog.Client, 66 enforcer *rbac.Enforcer, 67 ) *Repo { 68 return &Repo{oauth: oauth, 69 repoResolver: repoResolver, ··· 72 config: config, 73 spindlestream: spindlestream, 74 db: db, 75 - posthog: posthog, 76 enforcer: enforcer, 77 } 78 } 79 ··· 179 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 180 RepoInfo: f.RepoInfo(user), 181 }) 182 - return 183 } 184 185 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 374 375 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 376 // so we can safely redirect to the "parent" (which is the same file). 377 - if len(result.Files) == 0 && result.Parent == treePath { 378 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 379 return 380 } ··· 389 } 390 } 391 392 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 393 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 394 395 rp.pages.RepoTree(w, pages.RepoTreeParams{ 396 LoggedInUser: user, 397 BreadCrumbs: breadcrumbs, 398 - BaseTreeLink: baseTreeLink, 399 - BaseBlobLink: baseBlobLink, 400 RepoInfo: f.RepoInfo(user), 401 RepoTreeResponse: result, 402 }) 403 - return 404 } 405 406 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 458 ArtifactMap: artifactMap, 459 DanglingArtifacts: danglingArtifacts, 460 }) 461 - return 462 } 463 464 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 480 return 481 } 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 - }) 499 500 user := rp.oauth.GetUser(r) 501 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 503 RepoInfo: f.RepoInfo(user), 504 RepoBranchesResponse: *result, 505 }) 506 - return 507 } 508 509 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 554 showRendered = r.URL.Query().Get("code") != "true" 555 } 556 557 user := rp.oauth.GetUser(r) 558 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 559 LoggedInUser: user, ··· 562 BreadCrumbs: breadcrumbs, 563 ShowRendered: showRendered, 564 RenderToggle: renderToggle, 565 }) 566 - return 567 } 568 569 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 570 f, err := rp.repoResolver.Resolve(r) 571 if err != nil { 572 log.Println("failed to get repo and knot", err) 573 return 574 } 575 ··· 580 if !rp.config.Core.Dev { 581 protocol = "https" 582 } 583 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 584 if err != nil { 585 - log.Println("failed to reach knotserver", err) 586 return 587 } 588 589 - body, err := io.ReadAll(resp.Body) 590 - if err != nil { 591 - log.Printf("Error reading response body: %v", err) 592 return 593 } 594 595 - var result types.RepoBlobResponse 596 - err = json.Unmarshal(body, &result) 597 if err != nil { 598 - log.Println("failed to parse response:", err) 599 return 600 } 601 602 - if result.IsBinary { 603 - w.Header().Set("Content-Type", "application/octet-stream") 604 w.Write(body) 605 return 606 } 607 - 608 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 609 - w.Write([]byte(result.Contents)) 610 - return 611 } 612 613 // modify the spindle configured for this repo 614 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 615 f, err := rp.repoResolver.Resolve(r) 616 if err != nil { 617 - log.Println("failed to get repo and knot", err) 618 - w.WriteHeader(http.StatusBadRequest) 619 return 620 } 621 622 repoAt := f.RepoAt 623 rkey := repoAt.RecordKey().String() 624 if rkey == "" { 625 - log.Println("invalid aturi for repo", err) 626 - w.WriteHeader(http.StatusInternalServerError) 627 return 628 } 629 - 630 - user := rp.oauth.GetUser(r) 631 632 newSpindle := r.FormValue("spindle") 633 client, err := rp.oauth.AuthorizedClient(r) 634 if err != nil { 635 - log.Println("failed to get client") 636 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 637 return 638 } 639 640 // ensure that this is a valid spindle for this user 641 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 642 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.") 645 return 646 } 647 648 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.") 651 return 652 } 653 654 // optimistic update 655 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 656 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.") 659 return 660 } 661 662 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 663 if err != nil { 664 - // failed to get record 665 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 666 return 667 } 668 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 683 }) 684 685 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.") 689 return 690 } 691 ··· 695 eventconsumer.NewSpindleSource(newSpindle), 696 ) 697 698 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 699 } 700 701 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 702 f, err := rp.repoResolver.Resolve(r) 703 if err != nil { 704 - log.Println("failed to get repo and knot", err) 705 return 706 } 707 708 collaborator := r.FormValue("collaborator") 709 if collaborator == "" { 710 - http.Error(w, "malformed form", http.StatusBadRequest) 711 return 712 } 713 714 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 715 if err != nil { 716 - w.Write([]byte("failed to resolve collaborator did to a handle")) 717 return 718 } 719 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 720 721 - // TODO: create an atproto record for this 722 723 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 724 if err != nil { 725 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 726 return 727 } 728 729 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 730 if err != nil { 731 - log.Println("failed to create client to ", f.Knot) 732 return 733 } 734 735 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 736 if err != nil { 737 - log.Printf("failed to make request to %s: %s", f.Knot, err) 738 return 739 } 740 741 if ksResp.StatusCode != http.StatusNoContent { 742 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 743 return 744 } 745 746 tx, err := rp.db.BeginTx(r.Context(), nil) 747 if err != nil { 748 - log.Println("failed to start tx") 749 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 750 return 751 } 752 defer func() { 753 tx.Rollback() 754 err = rp.enforcer.E.LoadPolicy() 755 if err != nil { 756 - log.Println("failed to rollback policies") 757 } 758 }() 759 760 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 761 if err != nil { 762 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 763 return 764 } 765 766 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 767 if err != nil { 768 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 769 return 770 } 771 772 err = tx.Commit() 773 if err != nil { 774 - log.Println("failed to commit changes", err) 775 - http.Error(w, err.Error(), http.StatusInternalServerError) 776 return 777 } 778 779 err = rp.enforcer.E.SavePolicy() 780 if err != nil { 781 - log.Println("failed to update ACLs", err) 782 - http.Error(w, err.Error(), http.StatusInternalServerError) 783 return 784 } 785 786 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 787 - 788 } 789 790 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 936 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 937 } 938 939 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 940 f, err := rp.repoResolver.Resolve(r) 941 if err != nil { 942 log.Println("failed to get repo and knot", err) 943 return 944 } 945 946 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 - } 954 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 - } 961 } 962 963 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 964 if err != nil { 965 - log.Println("failed to create unsigned client", err) 966 return 967 } 968 969 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 970 if err != nil { 971 - log.Println("failed to reach knotserver", err) 972 return 973 } 974 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 980 } 981 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, 990 }) 991 } 992 } 993 994 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1108 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1109 sourceAt := f.RepoAt.String() 1110 1111 - rkey := appview.TID() 1112 repo := &db.Repo{ 1113 Did: user.Did, 1114 Name: forkName, ··· 1233 return 1234 } 1235 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 - }) 1239 1240 var defaultBranch string 1241 for _, b := range branches {
··· 8 "fmt" 9 "io" 10 "log" 11 + "log/slog" 12 "net/http" 13 "net/url" 14 + "path/filepath" 15 "slices" 16 "strconv" 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/commitverify" 22 "tangled.sh/tangled.sh/core/appview/config" 23 "tangled.sh/tangled.sh/core/appview/db" 24 + "tangled.sh/tangled.sh/core/appview/notify" 25 "tangled.sh/tangled.sh/core/appview/oauth" 26 "tangled.sh/tangled.sh/core/appview/pages" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 + "tangled.sh/tangled.sh/core/idresolver" 31 "tangled.sh/tangled.sh/core/knotclient" 32 "tangled.sh/tangled.sh/core/patchutil" 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 35 "tangled.sh/tangled.sh/core/types" 36 37 securejoin "github.com/cyphar/filepath-securejoin" 38 "github.com/go-chi/chi/v5" 39 "github.com/go-git/go-git/v5/plumbing" 40 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 lexutil "github.com/bluesky-social/indigo/lex/util" 44 ) 45 ··· 52 spindlestream *eventconsumer.Consumer 53 db *db.DB 54 enforcer *rbac.Enforcer 55 + notifier notify.Notifier 56 + logger *slog.Logger 57 } 58 59 func New( ··· 64 idResolver *idresolver.Resolver, 65 db *db.DB, 66 config *config.Config, 67 + notifier notify.Notifier, 68 enforcer *rbac.Enforcer, 69 + logger *slog.Logger, 70 ) *Repo { 71 return &Repo{oauth: oauth, 72 repoResolver: repoResolver, ··· 75 config: config, 76 spindlestream: spindlestream, 77 db: db, 78 + notifier: notifier, 79 enforcer: enforcer, 80 + logger: logger, 81 } 82 } 83 ··· 183 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 184 RepoInfo: f.RepoInfo(user), 185 }) 186 } 187 188 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 377 378 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 379 // so we can safely redirect to the "parent" (which is the same file). 380 + unescapedTreePath, _ := url.PathUnescape(treePath) 381 + if len(result.Files) == 0 && result.Parent == unescapedTreePath { 382 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 383 return 384 } ··· 393 } 394 } 395 396 + sortFiles(result.Files) 397 398 rp.pages.RepoTree(w, pages.RepoTreeParams{ 399 LoggedInUser: user, 400 BreadCrumbs: breadcrumbs, 401 + TreePath: treePath, 402 RepoInfo: f.RepoInfo(user), 403 RepoTreeResponse: result, 404 }) 405 } 406 407 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 459 ArtifactMap: artifactMap, 460 DanglingArtifacts: danglingArtifacts, 461 }) 462 } 463 464 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 480 return 481 } 482 483 + sortBranches(result.Branches) 484 485 user := rp.oauth.GetUser(r) 486 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 488 RepoInfo: f.RepoInfo(user), 489 RepoBranchesResponse: *result, 490 }) 491 } 492 493 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 538 showRendered = r.URL.Query().Get("code") != "true" 539 } 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 + 566 user := rp.oauth.GetUser(r) 567 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 568 LoggedInUser: user, ··· 571 BreadCrumbs: breadcrumbs, 572 ShowRendered: showRendered, 573 RenderToggle: renderToggle, 574 + Unsupported: unsupported, 575 + IsImage: isImage, 576 + IsVideo: isVideo, 577 + ContentSrc: contentSrc, 578 }) 579 } 580 581 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 582 f, err := rp.repoResolver.Resolve(r) 583 if err != nil { 584 log.Println("failed to get repo and knot", err) 585 + w.WriteHeader(http.StatusBadRequest) 586 return 587 } 588 ··· 593 if !rp.config.Core.Dev { 594 protocol = "https" 595 } 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) 598 if err != nil { 599 + log.Println("failed to reach knotserver:", err) 600 + rp.pages.Error503(w) 601 return 602 } 603 + defer resp.Body.Close() 604 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) 609 return 610 } 611 612 + contentType := resp.Header.Get("Content-Type") 613 + body, err := io.ReadAll(resp.Body) 614 if err != nil { 615 + log.Printf("error reading response body from knotserver: %v", err) 616 + w.WriteHeader(http.StatusInternalServerError) 617 return 618 } 619 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) 625 w.Write(body) 626 + } else { 627 + w.WriteHeader(http.StatusUnsupportedMediaType) 628 + w.Write([]byte("unsupported content type")) 629 return 630 } 631 } 632 633 // modify the spindle configured for this repo 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 + 646 f, err := rp.repoResolver.Resolve(r) 647 if err != nil { 648 + fail("Failed to resolve repo. Try again later", err) 649 return 650 } 651 652 repoAt := f.RepoAt 653 rkey := repoAt.RecordKey().String() 654 if rkey == "" { 655 + fail("Failed to resolve repo. Try again later", err) 656 return 657 } 658 659 newSpindle := r.FormValue("spindle") 660 client, err := rp.oauth.AuthorizedClient(r) 661 if err != nil { 662 + fail("Failed to authorize. Try again later.", err) 663 return 664 } 665 666 // ensure that this is a valid spindle for this user 667 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 668 if err != nil { 669 + fail("Failed to find spindles. Try again later.", err) 670 return 671 } 672 673 if !slices.Contains(validSpindles, newSpindle) { 674 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 675 return 676 } 677 678 // optimistic update 679 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 680 if err != nil { 681 + fail("Failed to update spindle. Try again later.", err) 682 return 683 } 684 685 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 686 if err != nil { 687 + fail("Failed to update spindle, no record found on PDS.", err) 688 return 689 } 690 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 705 }) 706 707 if err != nil { 708 + fail("Failed to update spindle, unable to save to PDS.", err) 709 return 710 } 711 ··· 715 eventconsumer.NewSpindleSource(newSpindle), 716 ) 717 718 + rp.pages.HxRefresh(w) 719 } 720 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 + 727 f, err := rp.repoResolver.Resolve(r) 728 if err != nil { 729 + l.Error("failed to get repo and knot", "err", err) 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) 737 } 738 739 collaborator := r.FormValue("collaborator") 740 if collaborator == "" { 741 + fail("Invalid form.", nil) 742 return 743 } 744 745 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 746 if err != nil { 747 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 748 + return 749 + } 750 + 751 + if collaboratorIdent.DID.String() == user.Did { 752 + fail("You seem to be adding yourself as a collaborator.", nil) 753 return 754 } 755 + l = l.With("collaborator", collaboratorIdent.Handle) 756 + l = l.With("knot", f.Knot) 757 758 + // announce this relation into the firehose, store into owners' pds 759 + client, err := rp.oauth.AuthorizedClient(r) 760 + if err != nil { 761 + fail("Failed to write to PDS.", err) 762 + return 763 + } 764 765 + // emit a record 766 + currentUser := rp.oauth.GetUser(r) 767 + rkey := tid.TID() 768 + createdAt := time.Now() 769 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 770 + Collection: tangled.RepoCollaboratorNSID, 771 + Repo: currentUser.Did, 772 + Rkey: rkey, 773 + Record: &lexutil.LexiconTypeDecoder{ 774 + Val: &tangled.RepoCollaborator{ 775 + Subject: collaboratorIdent.DID.String(), 776 + Repo: string(f.RepoAt), 777 + CreatedAt: createdAt.Format(time.RFC3339), 778 + }}, 779 + }) 780 + // invalid record 781 + if err != nil { 782 + fail("Failed to write record to PDS.", err) 783 + return 784 + } 785 + l = l.With("at-uri", resp.Uri) 786 + l.Info("wrote record to PDS") 787 + 788 + l.Info("adding to knot") 789 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 790 if err != nil { 791 + fail("Failed to add to knot.", err) 792 return 793 } 794 795 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 796 if err != nil { 797 + fail("Failed to add to knot.", err) 798 return 799 } 800 801 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 802 if err != nil { 803 + fail("Knot was unreachable.", err) 804 return 805 } 806 807 if ksResp.StatusCode != http.StatusNoContent { 808 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 809 return 810 } 811 812 tx, err := rp.db.BeginTx(r.Context(), nil) 813 if err != nil { 814 + fail("Failed to add collaborator.", err) 815 return 816 } 817 defer func() { 818 tx.Rollback() 819 err = rp.enforcer.E.LoadPolicy() 820 if err != nil { 821 + fail("Failed to add collaborator.", err) 822 } 823 }() 824 825 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 826 if err != nil { 827 + fail("Failed to add collaborator permissions.", err) 828 return 829 } 830 831 + err = db.AddCollaborator(rp.db, db.Collaborator{ 832 + Did: syntax.DID(currentUser.Did), 833 + Rkey: rkey, 834 + SubjectDid: collaboratorIdent.DID, 835 + RepoAt: f.RepoAt, 836 + Created: createdAt, 837 + }) 838 if err != nil { 839 + fail("Failed to add collaborator.", err) 840 return 841 } 842 843 err = tx.Commit() 844 if err != nil { 845 + fail("Failed to add collaborator.", err) 846 return 847 } 848 849 err = rp.enforcer.E.SavePolicy() 850 if err != nil { 851 + fail("Failed to update collaborator permissions.", err) 852 return 853 } 854 855 + rp.pages.HxRefresh(w) 856 } 857 858 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 1004 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1005 } 1006 1007 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1008 + user := rp.oauth.GetUser(r) 1009 + l := rp.logger.With("handler", "Secrets") 1010 + l = l.With("handle", user.Handle) 1011 + l = l.With("did", user.Did) 1012 + 1013 f, err := rp.repoResolver.Resolve(r) 1014 if err != nil { 1015 log.Println("failed to get repo and knot", err) 1016 return 1017 } 1018 1019 + if f.Spindle == "" { 1020 + log.Println("empty spindle cannot add/rm secret", err) 1021 + return 1022 + } 1023 + 1024 + lxm := tangled.RepoAddSecretNSID 1025 + if r.Method == http.MethodDelete { 1026 + lxm = tangled.RepoRemoveSecretNSID 1027 + } 1028 + 1029 + spindleClient, err := rp.oauth.ServiceClient( 1030 + r, 1031 + oauth.WithService(f.Spindle), 1032 + oauth.WithLxm(lxm), 1033 + oauth.WithDev(rp.config.Core.Dev), 1034 + ) 1035 + if err != nil { 1036 + log.Println("failed to create spindle client", err) 1037 + return 1038 + } 1039 + 1040 + key := r.FormValue("key") 1041 + if key == "" { 1042 + w.WriteHeader(http.StatusBadRequest) 1043 + return 1044 + } 1045 + 1046 switch r.Method { 1047 + case http.MethodPut: 1048 + errorId := "add-secret-error" 1049 1050 + value := r.FormValue("value") 1051 + if value == "" { 1052 + w.WriteHeader(http.StatusBadRequest) 1053 + return 1054 } 1055 1056 + err = tangled.RepoAddSecret( 1057 + r.Context(), 1058 + spindleClient, 1059 + &tangled.RepoAddSecret_Input{ 1060 + Repo: f.RepoAt.String(), 1061 + Key: key, 1062 + Value: value, 1063 + }, 1064 + ) 1065 if err != nil { 1066 + l.Error("Failed to add secret.", "err", err) 1067 + rp.pages.Notice(w, errorId, "Failed to add secret.") 1068 return 1069 } 1070 1071 + case http.MethodDelete: 1072 + errorId := "operation-error" 1073 + 1074 + err = tangled.RepoRemoveSecret( 1075 + r.Context(), 1076 + spindleClient, 1077 + &tangled.RepoRemoveSecret_Input{ 1078 + Repo: f.RepoAt.String(), 1079 + Key: key, 1080 + }, 1081 + ) 1082 if err != nil { 1083 + l.Error("Failed to delete secret.", "err", err) 1084 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 1085 return 1086 } 1087 + } 1088 1089 + rp.pages.HxRefresh(w) 1090 + } 1091 + 1092 + type tab = map[string]any 1093 + 1094 + var ( 1095 + // would be great to have ordered maps right about now 1096 + settingsTabs []tab = []tab{ 1097 + {"Name": "general", "Icon": "sliders-horizontal"}, 1098 + {"Name": "access", "Icon": "users"}, 1099 + {"Name": "pipelines", "Icon": "layers-2"}, 1100 + } 1101 + ) 1102 + 1103 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1104 + tabVal := r.URL.Query().Get("tab") 1105 + if tabVal == "" { 1106 + tabVal = "general" 1107 + } 1108 + 1109 + switch tabVal { 1110 + case "general": 1111 + rp.generalSettings(w, r) 1112 + 1113 + case "access": 1114 + rp.accessSettings(w, r) 1115 + 1116 + case "pipelines": 1117 + rp.pipelineSettings(w, r) 1118 + } 1119 + 1120 + // user := rp.oauth.GetUser(r) 1121 + // repoCollaborators, err := f.Collaborators(r.Context()) 1122 + // if err != nil { 1123 + // log.Println("failed to get collaborators", err) 1124 + // } 1125 + 1126 + // isCollaboratorInviteAllowed := false 1127 + // if user != nil { 1128 + // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1129 + // if err == nil && ok { 1130 + // isCollaboratorInviteAllowed = true 1131 + // } 1132 + // } 1133 + 1134 + // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1135 + // if err != nil { 1136 + // log.Println("failed to create unsigned client", err) 1137 + // return 1138 + // } 1139 + 1140 + // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1141 + // if err != nil { 1142 + // log.Println("failed to reach knotserver", err) 1143 + // return 1144 + // } 1145 + 1146 + // // all spindles that this user is a member of 1147 + // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1148 + // if err != nil { 1149 + // log.Println("failed to fetch spindles", err) 1150 + // return 1151 + // } 1152 + 1153 + // var secrets []*tangled.RepoListSecrets_Secret 1154 + // if f.Spindle != "" { 1155 + // if spindleClient, err := rp.oauth.ServiceClient( 1156 + // r, 1157 + // oauth.WithService(f.Spindle), 1158 + // oauth.WithLxm(tangled.RepoListSecretsNSID), 1159 + // oauth.WithDev(rp.config.Core.Dev), 1160 + // ); err != nil { 1161 + // log.Println("failed to create spindle client", err) 1162 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1163 + // log.Println("failed to fetch secrets", err) 1164 + // } else { 1165 + // secrets = resp.Secrets 1166 + // } 1167 + // } 1168 + 1169 + // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1170 + // LoggedInUser: user, 1171 + // RepoInfo: f.RepoInfo(user), 1172 + // Collaborators: repoCollaborators, 1173 + // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1174 + // Branches: result.Branches, 1175 + // Spindles: spindles, 1176 + // CurrentSpindle: f.Spindle, 1177 + // Secrets: secrets, 1178 + // }) 1179 + } 1180 + 1181 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1182 + f, err := rp.repoResolver.Resolve(r) 1183 + user := rp.oauth.GetUser(r) 1184 + 1185 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1186 + if err != nil { 1187 + log.Println("failed to create unsigned client", err) 1188 + return 1189 + } 1190 + 1191 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1192 + if err != nil { 1193 + log.Println("failed to reach knotserver", err) 1194 + return 1195 + } 1196 + 1197 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1198 + LoggedInUser: user, 1199 + RepoInfo: f.RepoInfo(user), 1200 + Branches: result.Branches, 1201 + Tabs: settingsTabs, 1202 + Tab: "general", 1203 + }) 1204 + } 1205 + 1206 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1207 + f, err := rp.repoResolver.Resolve(r) 1208 + user := rp.oauth.GetUser(r) 1209 + 1210 + repoCollaborators, err := f.Collaborators(r.Context()) 1211 + if err != nil { 1212 + log.Println("failed to get collaborators", err) 1213 + } 1214 + 1215 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1216 + LoggedInUser: user, 1217 + RepoInfo: f.RepoInfo(user), 1218 + Tabs: settingsTabs, 1219 + Tab: "access", 1220 + Collaborators: repoCollaborators, 1221 + }) 1222 + } 1223 + 1224 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1225 + f, err := rp.repoResolver.Resolve(r) 1226 + user := rp.oauth.GetUser(r) 1227 + 1228 + // all spindles that the repo owner is a member of 1229 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1230 + if err != nil { 1231 + log.Println("failed to fetch spindles", err) 1232 + return 1233 + } 1234 + 1235 + var secrets []*tangled.RepoListSecrets_Secret 1236 + if f.Spindle != "" { 1237 + if spindleClient, err := rp.oauth.ServiceClient( 1238 + r, 1239 + oauth.WithService(f.Spindle), 1240 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1241 + oauth.WithDev(rp.config.Core.Dev), 1242 + ); err != nil { 1243 + log.Println("failed to create spindle client", err) 1244 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1245 + log.Println("failed to fetch secrets", err) 1246 + } else { 1247 + secrets = resp.Secrets 1248 } 1249 + } 1250 1251 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1252 + return strings.Compare(a.Key, b.Key) 1253 + }) 1254 + 1255 + var dids []string 1256 + for _, s := range secrets { 1257 + dids = append(dids, s.CreatedBy) 1258 + } 1259 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1260 + 1261 + // convert to a more manageable form 1262 + var niceSecret []map[string]any 1263 + for id, s := range secrets { 1264 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1265 + niceSecret = append(niceSecret, map[string]any{ 1266 + "Id": id, 1267 + "Key": s.Key, 1268 + "CreatedAt": when, 1269 + "CreatedBy": resolvedIdents[id].Handle.String(), 1270 }) 1271 } 1272 + 1273 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1274 + LoggedInUser: user, 1275 + RepoInfo: f.RepoInfo(user), 1276 + Tabs: settingsTabs, 1277 + Tab: "pipelines", 1278 + Spindles: spindles, 1279 + CurrentSpindle: f.Spindle, 1280 + Secrets: niceSecret, 1281 + }) 1282 } 1283 1284 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1398 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1399 sourceAt := f.RepoAt.String() 1400 1401 + rkey := tid.TID() 1402 repo := &db.Repo{ 1403 Did: user.Did, 1404 Name: forkName, ··· 1523 return 1524 } 1525 branches := result.Branches 1526 + 1527 + sortBranches(branches) 1528 1529 var defaultBranch string 1530 for _, b := range branches {
+34
appview/repo/repo_util.go
··· 5 "crypto/rand" 6 "fmt" 7 "math/big" 8 9 "tangled.sh/tangled.sh/core/appview/db" 10 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 11 12 "github.com/go-git/go-git/v5/plumbing/object" 13 ) 14 15 func uniqueEmails(commits []*object.Commit) []string { 16 emails := make(map[string]struct{})
··· 5 "crypto/rand" 6 "fmt" 7 "math/big" 8 + "slices" 9 + "sort" 10 + "strings" 11 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 + "tangled.sh/tangled.sh/core/types" 15 16 "github.com/go-git/go-git/v5/plumbing/object" 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 + } 48 49 func uniqueEmails(commits []*object.Commit) []string { 50 emails := make(map[string]struct{})
+2
appview/repo/router.go
··· 74 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 r.Put("/branches/default", rp.SetDefaultBranch) 77 }) 78 }) 79
··· 74 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 r.Put("/branches/default", rp.SetDefaultBranch) 77 + r.Put("/secrets", rp.Secrets) 78 + r.Delete("/secrets", rp.Secrets) 79 }) 80 }) 81
+5 -4
appview/reporesolver/resolver.go
··· 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) ··· 149 for _, item := range repoCollaborators { 150 // currently only two roles: owner and member 151 var role string 152 - if item[3] == "repo:owner" { 153 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 155 role = "collaborator" 156 - } else { 157 continue 158 } 159
··· 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) ··· 149 for _, item := range repoCollaborators { 150 // currently only two roles: owner and member 151 var role string 152 + switch item[3] { 153 + case "repo:owner": 154 role = "owner" 155 + case "repo:collaborator": 156 role = "collaborator" 157 + default: 158 continue 159 } 160
+2 -2
appview/settings/settings.go
··· 12 13 "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/email" 19 "tangled.sh/tangled.sh/core/appview/middleware" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 return 367 } 368 369 - rkey := appview.TID() 370 371 tx, err := s.Db.Begin() 372 if err != nil {
··· 12 13 "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 "tangled.sh/tangled.sh/core/appview/email" 18 "tangled.sh/tangled.sh/core/appview/middleware" 19 "tangled.sh/tangled.sh/core/appview/oauth" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/tid" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 return 367 } 368 369 + rkey := tid.TID() 370 371 tx, err := s.Db.Begin() 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 + }
+256
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.Get("/", s.signup) 108 + r.Post("/", s.signup) 109 + r.Get("/complete", s.complete) 110 + r.Post("/complete", s.complete) 111 + 112 + return r 113 + } 114 + 115 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 + switch r.Method { 117 + case http.MethodGet: 118 + s.pages.Signup(w) 119 + case http.MethodPost: 120 + if s.cf == nil { 121 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 + } 123 + emailId := r.FormValue("email") 124 + 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 130 + 131 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 + if err != nil { 133 + s.l.Error("failed to check email existence", "error", err) 134 + s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 + return 136 + } 137 + if exists { 138 + s.pages.Notice(w, noticeId, "Email already exists.") 139 + return 140 + } 141 + 142 + code, err := s.inviteCodeRequest() 143 + if err != nil { 144 + s.l.Error("failed to create invite code", "error", err) 145 + s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 + return 147 + } 148 + 149 + em := email.Email{ 150 + APIKey: s.config.Resend.ApiKey, 151 + From: s.config.Resend.SentFrom, 152 + To: emailId, 153 + Subject: "Verify your Tangled account", 154 + Text: `Copy and paste this code below to verify your account on Tangled. 155 + ` + code, 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 157 + <p><code>` + code + `</code></p>`, 158 + } 159 + 160 + err = email.SendEmail(em) 161 + if err != nil { 162 + s.l.Error("failed to send email", "error", err) 163 + s.pages.Notice(w, noticeId, "Failed to send email.") 164 + return 165 + } 166 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 + Email: emailId, 168 + InviteCode: code, 169 + }) 170 + if err != nil { 171 + s.l.Error("failed to add inflight signup", "error", err) 172 + s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 173 + return 174 + } 175 + 176 + s.pages.HxRedirect(w, "/signup/complete") 177 + } 178 + } 179 + 180 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 181 + switch r.Method { 182 + case http.MethodGet: 183 + s.pages.CompleteSignup(w) 184 + case http.MethodPost: 185 + username := r.FormValue("username") 186 + password := r.FormValue("password") 187 + code := r.FormValue("code") 188 + 189 + if !userutil.IsValidSubdomain(username) { 190 + 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.") 191 + return 192 + } 193 + 194 + if !s.isNicknameAllowed(username) { 195 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 196 + return 197 + } 198 + 199 + email, err := db.GetEmailForCode(s.db, code) 200 + if err != nil { 201 + s.l.Error("failed to get email for code", "error", err) 202 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 203 + return 204 + } 205 + 206 + did, err := s.createAccountRequest(username, password, email, code) 207 + if err != nil { 208 + s.l.Error("failed to create account", "error", err) 209 + s.pages.Notice(w, "signup-error", err.Error()) 210 + return 211 + } 212 + 213 + if s.cf == nil { 214 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 215 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 216 + return 217 + } 218 + 219 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 220 + Type: "TXT", 221 + Name: "_atproto." + username, 222 + Content: "did=" + did, 223 + TTL: 6400, 224 + Proxied: false, 225 + }) 226 + if err != nil { 227 + s.l.Error("failed to create DNS record", "error", err) 228 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + return 230 + } 231 + 232 + err = db.AddEmail(s.db, db.Email{ 233 + Did: did, 234 + Address: email, 235 + Verified: true, 236 + Primary: true, 237 + }) 238 + if err != nil { 239 + s.l.Error("failed to add email", "error", err) 240 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 241 + return 242 + } 243 + 244 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 245 + <a class="underline text-black dark:text-white" href="/login">login</a> 246 + with <code>%s.tngl.sh</code>.`, username)) 247 + 248 + go func() { 249 + err := db.DeleteInflightSignup(s.db, email) 250 + if err != nil { 251 + s.l.Error("failed to delete inflight signup", "error", err) 252 + } 253 + }() 254 + return 255 + } 256 + }
+16 -8
appview/spindles/spindles.go
··· 10 11 "github.com/go-chi/chi/v5" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 "tangled.sh/tangled.sh/core/appview/config" 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/core/appview/middleware" 18 "tangled.sh/tangled.sh/core/appview/oauth" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 21 "tangled.sh/tangled.sh/core/rbac" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 114 } 115 116 identsToResolve := make([]string, len(members)) 117 - for i, member := range members { 118 - identsToResolve[i] = member 119 - } 120 resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 121 didHandleMap := make(map[string]string) 122 for _, identity := range resolvedIds { ··· 258 259 // ok 260 s.Pages.HxRefresh(w) 261 - return 262 } 263 264 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 306 s.Enforcer.E.LoadPolicy() 307 }() 308 309 err = db.DeleteSpindle( 310 tx, 311 db.FilterEq("owner", user.Did), ··· 524 s.Enforcer.E.LoadPolicy() 525 }() 526 527 - rkey := appview.TID() 528 529 // add member to db 530 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 711 712 // ok 713 s.Pages.HxRefresh(w) 714 - return 715 }
··· 10 11 "github.com/go-chi/chi/v5" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/appview/config" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 + "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 + "tangled.sh/tangled.sh/core/tid" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 114 } 115 116 identsToResolve := make([]string, len(members)) 117 + copy(identsToResolve, members) 118 resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 119 didHandleMap := make(map[string]string) 120 for _, identity := range resolvedIds { ··· 256 257 // ok 258 s.Pages.HxRefresh(w) 259 } 260 261 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 303 s.Enforcer.E.LoadPolicy() 304 }() 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 + 318 err = db.DeleteSpindle( 319 tx, 320 db.FilterEq("owner", user.Did), ··· 533 s.Enforcer.E.LoadPolicy() 534 }() 535 536 + rkey := tid.TID() 537 538 // add member to db 539 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 720 721 // ok 722 s.Pages.HxRefresh(w) 723 }
+13 -26
appview/state/follow.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/posthog/posthog-go" 11 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/pages" 15 ) 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 switch r.Method { 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 - rkey := appview.TID() 46 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, ··· 58 return 59 } 60 61 - err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey) 62 if err != nil { 63 log.Println("failed to follow", err) 64 return 65 } 66 67 - log.Println("created atproto record: ", resp.Uri) 68 69 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 70 UserDid: subjectIdent.DID.String(), 71 FollowStatus: db.IsFollowing, 72 }) 73 74 - if !s.config.Core.Dev { 75 - err = s.posthog.Enqueue(posthog.Capture{ 76 - DistinctId: currentUser.Did, 77 - Event: "follow", 78 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 79 - }) 80 - if err != nil { 81 - log.Println("failed to enqueue posthog event:", err) 82 - } 83 - } 84 - 85 return 86 case http.MethodDelete: 87 // find the record in the db ··· 113 FollowStatus: db.IsNotFollowing, 114 }) 115 116 - if !s.config.Core.Dev { 117 - err = s.posthog.Enqueue(posthog.Capture{ 118 - DistinctId: currentUser.Did, 119 - Event: "unfollow", 120 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 121 - }) 122 - if err != nil { 123 - log.Println("failed to enqueue posthog event:", err) 124 - } 125 - } 126 127 return 128 }
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 + "tangled.sh/tangled.sh/core/tid" 14 ) 15 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 41 switch r.Method { 42 case http.MethodPost: 43 createdAt := time.Now().Format(time.RFC3339) 44 + rkey := tid.TID() 45 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 Collection: tangled.GraphFollowNSID, 47 Repo: currentUser.Did, ··· 57 return 58 } 59 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) 69 if err != nil { 70 log.Println("failed to follow", err) 71 return 72 } 73 74 + s.notifier.NewFollow(r.Context(), follow) 75 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 UserDid: subjectIdent.DID.String(), 78 FollowStatus: db.IsFollowing, 79 }) 80 81 return 82 case http.MethodDelete: 83 // find the record in the db ··· 109 FollowStatus: db.IsNotFollowing, 110 }) 111 112 + s.notifier.DeleteFollow(r.Context(), follow) 113 114 return 115 }
+1 -29
appview/state/profile.go
··· 1 package state 2 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 "fmt" 8 "log" 9 "net/http" ··· 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages" ··· 143 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 144 } 145 146 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 147 s.pages.ProfilePage(w, pages.ProfilePageParams{ 148 LoggedInUser: loggedInUser, 149 Repos: pinnedRepos, ··· 152 Card: pages.ProfileCard{ 153 UserDid: ident.DID.String(), 154 UserHandle: ident.Handle.String(), 155 - AvatarUri: profileAvatarUri, 156 Profile: profile, 157 FollowStatus: followStatus, 158 Followers: followers, ··· 195 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 196 } 197 198 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 199 - 200 s.pages.ReposPage(w, pages.ReposPageParams{ 201 LoggedInUser: loggedInUser, 202 Repos: repos, ··· 204 Card: pages.ProfileCard{ 205 UserDid: ident.DID.String(), 206 UserHandle: ident.Handle.String(), 207 - AvatarUri: profileAvatarUri, 208 Profile: profile, 209 FollowStatus: followStatus, 210 Followers: followers, ··· 213 }) 214 } 215 216 - func (s *State) GetAvatarUri(handle string) string { 217 - secret := s.config.Avatar.SharedSecret 218 - h := hmac.New(sha256.New, []byte(secret)) 219 - h.Write([]byte(handle)) 220 - signature := hex.EncodeToString(h.Sum(nil)) 221 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 222 - } 223 - 224 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 225 user := s.oauth.GetUser(r) 226 ··· 266 } 267 268 s.updateProfile(profile, w, r) 269 - return 270 } 271 272 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 306 profile.PinnedRepos = pinnedRepos 307 308 s.updateProfile(profile, w, r) 309 - return 310 } 311 312 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 371 return 372 } 373 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 - } 383 384 s.pages.HxRedirect(w, "/"+user.Did) 385 - return 386 } 387 388 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
··· 1 package state 2 3 import ( 4 "fmt" 5 "log" 6 "net/http" ··· 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "github.com/go-chi/chi/v5" 16 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" ··· 139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 140 } 141 142 s.pages.ProfilePage(w, pages.ProfilePageParams{ 143 LoggedInUser: loggedInUser, 144 Repos: pinnedRepos, ··· 147 Card: pages.ProfileCard{ 148 UserDid: ident.DID.String(), 149 UserHandle: ident.Handle.String(), 150 Profile: profile, 151 FollowStatus: followStatus, 152 Followers: followers, ··· 189 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 190 } 191 192 s.pages.ReposPage(w, pages.ReposPageParams{ 193 LoggedInUser: loggedInUser, 194 Repos: repos, ··· 196 Card: pages.ProfileCard{ 197 UserDid: ident.DID.String(), 198 UserHandle: ident.Handle.String(), 199 Profile: profile, 200 FollowStatus: followStatus, 201 Followers: followers, ··· 204 }) 205 } 206 207 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 208 user := s.oauth.GetUser(r) 209 ··· 249 } 250 251 s.updateProfile(profile, w, r) 252 } 253 254 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 288 profile.PinnedRepos = pinnedRepos 289 290 s.updateProfile(profile, w, r) 291 } 292 293 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 352 return 353 } 354 355 + s.notifier.UpdateProfile(r.Context(), profile) 356 357 s.pages.HxRedirect(w, "/"+user.Did) 358 } 359 360 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+8 -8
appview/state/reaction.go
··· 10 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 ) 17 18 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 45 switch r.Method { 46 case http.MethodPost: 47 createdAt := time.Now().Format(time.RFC3339) 48 - rkey := appview.TID() 49 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 Collection: tangled.FeedReactionNSID, 51 Repo: currentUser.Did, ··· 77 log.Println("created atproto record: ", resp.Uri) 78 79 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 - ThreadAt: subjectUri, 81 - Kind: reactionKind, 82 - Count: count, 83 IsReacted: true, 84 }) 85 ··· 115 } 116 117 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 - ThreadAt: subjectUri, 119 - Kind: reactionKind, 120 - Count: count, 121 IsReacted: false, 122 }) 123
··· 10 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/pages" 15 + "tangled.sh/tangled.sh/core/tid" 16 ) 17 18 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 45 switch r.Method { 46 case http.MethodPost: 47 createdAt := time.Now().Format(time.RFC3339) 48 + rkey := tid.TID() 49 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 Collection: tangled.FeedReactionNSID, 51 Repo: currentUser.Did, ··· 77 log.Println("created atproto record: ", resp.Uri) 78 79 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 IsReacted: true, 84 }) 85 ··· 115 } 116 117 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 IsReacted: false, 122 }) 123
+35 -7
appview/state/router.go
··· 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 "tangled.sh/tangled.sh/core/appview/repo" 16 "tangled.sh/tangled.sh/core/appview/settings" 17 "tangled.sh/tangled.sh/core/appview/spindles" 18 "tangled.sh/tangled.sh/core/appview/state/userutil" 19 "tangled.sh/tangled.sh/core/log" 20 ) 21 ··· 65 66 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 67 r := chi.NewRouter() 68 - 69 - // strip @ from user 70 - r.Use(middleware.StripLeadingAt) 71 72 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 73 r.Get("/", s.Profile) ··· 135 }) 136 137 r.Mount("/settings", s.SettingsRouter()) 138 r.Mount("/knots", s.KnotsRouter(mw)) 139 r.Mount("/spindles", s.SpindlesRouter()) 140 r.Mount("/", s.OAuthRouter()) 141 142 r.Get("/keys/{user}", s.Keys) 143 144 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 145 s.pages.Error404(w) ··· 197 return knots.Router(mw) 198 } 199 200 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) 202 return issues.Router(mw) 203 } 204 205 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) 207 return pulls.Router(mw) 208 } 209 210 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) 212 return repo.Router(mw) 213 } 214 215 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) 217 return pipes.Router(mw) 218 }
··· 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 "tangled.sh/tangled.sh/core/appview/repo" 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 21 "tangled.sh/tangled.sh/core/log" 22 ) 23 ··· 67 68 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 69 r := chi.NewRouter() 70 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 r.Get("/", s.Profile) ··· 134 }) 135 136 r.Mount("/settings", s.SettingsRouter()) 137 + r.Mount("/strings", s.StringsRouter(mw)) 138 r.Mount("/knots", s.KnotsRouter(mw)) 139 r.Mount("/spindles", s.SpindlesRouter()) 140 + r.Mount("/signup", s.SignupRouter()) 141 r.Mount("/", s.OAuthRouter()) 142 143 r.Get("/keys/{user}", s.Keys) 144 + r.Get("/terms", s.TermsOfService) 145 + r.Get("/privacy", s.PrivacyPolicy) 146 147 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 148 s.pages.Error404(w) ··· 200 return knots.Router(mw) 201 } 202 203 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 204 + logger := log.New("strings") 205 + 206 + strs := &avstrings.Strings{ 207 + Db: s.db, 208 + OAuth: s.oauth, 209 + Pages: s.pages, 210 + Config: s.config, 211 + Enforcer: s.enforcer, 212 + IdResolver: s.idResolver, 213 + Knotstream: s.knotstream, 214 + Logger: logger, 215 + } 216 + 217 + return strs.Router(mw) 218 + } 219 + 220 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 221 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 222 return issues.Router(mw) 223 } 224 225 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 226 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 227 return pulls.Router(mw) 228 } 229 230 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 231 + logger := log.New("repo") 232 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 233 return repo.Router(mw) 234 } 235 236 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 237 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 238 return pipes.Router(mw) 239 } 240 + 241 + func (s *State) SignupRouter() http.Handler { 242 + logger := log.New("signup") 243 + 244 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 245 + return sig.Router() 246 + }
+15 -29
appview/state/star.go
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "github.com/posthog/posthog-go" 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 ) 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 switch r.Method { 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 - rkey := appview.TID() 43 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, ··· 54 log.Println("failed to create atproto record", err) 55 return 56 } 57 58 - err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey) 59 if err != nil { 60 log.Println("failed to star", err) 61 return ··· 66 log.Println("failed to get star count for ", subjectUri) 67 } 68 69 - log.Println("created atproto record: ", resp.Uri) 70 71 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 72 IsStarred: true, 73 RepoAt: subjectUri, 74 Stats: db.RepoStats{ ··· 76 }, 77 }) 78 79 - if !s.config.Core.Dev { 80 - err = s.posthog.Enqueue(posthog.Capture{ 81 - DistinctId: currentUser.Did, 82 - Event: "star", 83 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 84 - }) 85 - if err != nil { 86 - log.Println("failed to enqueue posthog event:", err) 87 - } 88 - } 89 - 90 return 91 case http.MethodDelete: 92 // find the record in the db ··· 119 return 120 } 121 122 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 123 IsStarred: false, 124 RepoAt: subjectUri, 125 Stats: db.RepoStats{ 126 StarCount: starCount, 127 }, 128 }) 129 - 130 - if !s.config.Core.Dev { 131 - err = s.posthog.Enqueue(posthog.Capture{ 132 - DistinctId: currentUser.Did, 133 - Event: "unstar", 134 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 135 - }) 136 - if err != nil { 137 - log.Println("failed to enqueue posthog event:", err) 138 - } 139 - } 140 141 return 142 }
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 + "tangled.sh/tangled.sh/core/tid" 15 ) 16 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 38 switch r.Method { 39 case http.MethodPost: 40 createdAt := time.Now().Format(time.RFC3339) 41 + rkey := tid.TID() 42 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 Collection: tangled.FeedStarNSID, 44 Repo: currentUser.Did, ··· 53 log.Println("failed to create atproto record", err) 54 return 55 } 56 + log.Println("created atproto record: ", resp.Uri) 57 58 + star := &db.Star{ 59 + StarredByDid: currentUser.Did, 60 + RepoAt: subjectUri, 61 + Rkey: rkey, 62 + } 63 + 64 + err = db.AddStar(s.db, star) 65 if err != nil { 66 log.Println("failed to star", err) 67 return ··· 72 log.Println("failed to get star count for ", subjectUri) 73 } 74 75 + s.notifier.NewStar(r.Context(), star) 76 77 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 IsStarred: true, 79 RepoAt: subjectUri, 80 Stats: db.RepoStats{ ··· 82 }, 83 }) 84 85 return 86 case http.MethodDelete: 87 // find the record in the db ··· 114 return 115 } 116 117 + s.notifier.DeleteStar(r.Context(), star) 118 + 119 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 IsStarred: false, 121 RepoAt: subjectUri, 122 Stats: db.RepoStats{ 123 StarCount: starCount, 124 }, 125 }) 126 127 return 128 }
+28 -356
appview/state/state.go
··· 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" ··· 21 "tangled.sh/tangled.sh/core/appview/cache/session" 22 "tangled.sh/tangled.sh/core/appview/config" 23 "tangled.sh/tangled.sh/core/appview/db" 24 - "tangled.sh/tangled.sh/core/appview/idresolver" 25 "tangled.sh/tangled.sh/core/appview/oauth" 26 "tangled.sh/tangled.sh/core/appview/pages" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 "tangled.sh/tangled.sh/core/jetstream" 30 "tangled.sh/tangled.sh/core/knotclient" 31 tlog "tangled.sh/tangled.sh/core/log" 32 "tangled.sh/tangled.sh/core/rbac" 33 ) 34 35 type State struct { 36 db *db.DB 37 oauth *oauth.OAuth 38 enforcer *rbac.Enforcer 39 - tidClock syntax.TIDClock 40 pages *pages.Pages 41 sess *session.SessionStore 42 idResolver *idresolver.Resolver ··· 59 return nil, fmt.Errorf("failed to create enforcer: %w", err) 60 } 61 62 - clock := syntax.NewTIDClock(0) 63 - 64 pgs := pages.NewPages(config) 65 66 - res, err := idresolver.RedisResolver(config.Redis) 67 if err != nil { 68 log.Printf("failed to create redis resolver: %v", err) 69 res = idresolver.DefaultResolver() ··· 93 tangled.ActorProfileNSID, 94 tangled.SpindleMemberNSID, 95 tangled.SpindleNSID, 96 }, 97 nil, 98 slog.Default(), ··· 131 } 132 spindlestream.Start(ctx) 133 134 state := &State{ 135 d, 136 oauth, 137 enforcer, 138 - clock, 139 pgs, 140 sess, 141 res, ··· 150 return state, nil 151 } 152 153 - func TID(c *syntax.TIDClock) string { 154 - return c.Next().String() 155 } 156 157 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 198 return 199 } 200 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 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 238 user := chi.URLParam(r, "user") 239 user = strings.TrimPrefix(user, "@") ··· 266 } 267 } 268 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 func validateRepoName(name string) error { 570 // check for path traversal attempts 571 if name == "." || name == ".." || ··· 664 return 665 } 666 667 - rkey := appview.TID() 668 repo := &db.Repo{ 669 Did: user.Did, 670 Name: repoName, ··· 760 return 761 } 762 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 - } 773 774 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 775 return
··· 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/go-chi/chi/v5" ··· 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 + "tangled.sh/tangled.sh/core/idresolver" 30 "tangled.sh/tangled.sh/core/jetstream" 31 "tangled.sh/tangled.sh/core/knotclient" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 35 ) 36 37 type State struct { 38 db *db.DB 39 + notifier notify.Notifier 40 oauth *oauth.OAuth 41 enforcer *rbac.Enforcer 42 pages *pages.Pages 43 sess *session.SessionStore 44 idResolver *idresolver.Resolver ··· 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 } 63 64 pgs := pages.NewPages(config) 65 66 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 if err != nil { 68 log.Printf("failed to create redis resolver: %v", err) 69 res = idresolver.DefaultResolver() ··· 93 tangled.ActorProfileNSID, 94 tangled.SpindleMemberNSID, 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 97 }, 98 nil, 99 slog.Default(), ··· 132 } 133 spindlestream.Start(ctx) 134 135 + var notifiers []notify.Notifier 136 + if !config.Core.Dev { 137 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 138 + } 139 + notifier := notify.NewMergedNotifier(notifiers...) 140 + 141 state := &State{ 142 d, 143 + notifier, 144 oauth, 145 enforcer, 146 pgs, 147 sess, 148 res, ··· 157 return state, nil 158 } 159 160 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 161 + user := s.oauth.GetUser(r) 162 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 163 + LoggedInUser: user, 164 + }) 165 + } 166 + 167 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 168 + user := s.oauth.GetUser(r) 169 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 170 + LoggedInUser: user, 171 + }) 172 } 173 174 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 215 return 216 } 217 218 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 219 user := chi.URLParam(r, "user") 220 user = strings.TrimPrefix(user, "@") ··· 247 } 248 } 249 250 func validateRepoName(name string) error { 251 // check for path traversal attempts 252 if name == "." || name == ".." || ··· 345 return 346 } 347 348 + rkey := tid.TID() 349 repo := &db.Repo{ 350 Did: user.Did, 351 Name: repoName, ··· 441 return 442 } 443 444 + s.notifier.NewRepo(r.Context(), repo) 445 446 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 447 return
+6
appview/state/userutil/userutil.go
··· 51 func IsDid(s string) bool { 52 return didRegex.MatchString(s) 53 }
··· 51 func IsDid(s string) bool { 52 return didRegex.MatchString(s) 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 + }
+449
appview/strings/strings.go
···
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "slices" 9 + "strconv" 10 + "strings" 11 + "time" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/appview/config" 15 + "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/middleware" 17 + "tangled.sh/tangled.sh/core/appview/oauth" 18 + "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/pages/markup" 20 + "tangled.sh/tangled.sh/core/eventconsumer" 21 + "tangled.sh/tangled.sh/core/idresolver" 22 + "tangled.sh/tangled.sh/core/rbac" 23 + "tangled.sh/tangled.sh/core/tid" 24 + 25 + "github.com/bluesky-social/indigo/api/atproto" 26 + "github.com/bluesky-social/indigo/atproto/identity" 27 + "github.com/bluesky-social/indigo/atproto/syntax" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 + "github.com/go-chi/chi/v5" 30 + ) 31 + 32 + type Strings struct { 33 + Db *db.DB 34 + OAuth *oauth.OAuth 35 + Pages *pages.Pages 36 + Config *config.Config 37 + Enforcer *rbac.Enforcer 38 + IdResolver *idresolver.Resolver 39 + Logger *slog.Logger 40 + Knotstream *eventconsumer.Consumer 41 + } 42 + 43 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 44 + r := chi.NewRouter() 45 + 46 + r. 47 + With(mw.ResolveIdent()). 48 + Route("/{user}", func(r chi.Router) { 49 + r.Get("/", s.dashboard) 50 + 51 + r.Route("/{rkey}", func(r chi.Router) { 52 + r.Get("/", s.contents) 53 + r.Delete("/", s.delete) 54 + r.Get("/raw", s.contents) 55 + r.Get("/edit", s.edit) 56 + r.Post("/edit", s.edit) 57 + r. 58 + With(middleware.AuthMiddleware(s.OAuth)). 59 + Post("/comment", s.comment) 60 + }) 61 + }) 62 + 63 + r. 64 + With(middleware.AuthMiddleware(s.OAuth)). 65 + Route("/new", func(r chi.Router) { 66 + r.Get("/", s.create) 67 + r.Post("/", s.create) 68 + }) 69 + 70 + return r 71 + } 72 + 73 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 + l := s.Logger.With("handler", "contents") 75 + 76 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 77 + if !ok { 78 + l.Error("malformed middleware") 79 + w.WriteHeader(http.StatusInternalServerError) 80 + return 81 + } 82 + l = l.With("did", id.DID, "handle", id.Handle) 83 + 84 + rkey := chi.URLParam(r, "rkey") 85 + if rkey == "" { 86 + l.Error("malformed url, empty rkey") 87 + w.WriteHeader(http.StatusBadRequest) 88 + return 89 + } 90 + l = l.With("rkey", rkey) 91 + 92 + strings, err := db.GetStrings( 93 + s.Db, 94 + db.FilterEq("did", id.DID), 95 + db.FilterEq("rkey", rkey), 96 + ) 97 + if err != nil { 98 + l.Error("failed to fetch string", "err", err) 99 + w.WriteHeader(http.StatusInternalServerError) 100 + return 101 + } 102 + if len(strings) != 1 { 103 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 104 + w.WriteHeader(http.StatusInternalServerError) 105 + return 106 + } 107 + string := strings[0] 108 + 109 + if path.Base(r.URL.Path) == "raw" { 110 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 111 + if string.Filename != "" { 112 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 113 + } 114 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 115 + 116 + _, err = w.Write([]byte(string.Contents)) 117 + if err != nil { 118 + l.Error("failed to write raw response", "err", err) 119 + } 120 + return 121 + } 122 + 123 + var showRendered, renderToggle bool 124 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 125 + renderToggle = true 126 + showRendered = r.URL.Query().Get("code") != "true" 127 + } 128 + 129 + s.Pages.SingleString(w, pages.SingleStringParams{ 130 + LoggedInUser: s.OAuth.GetUser(r), 131 + RenderToggle: renderToggle, 132 + ShowRendered: showRendered, 133 + String: string, 134 + Stats: string.Stats(), 135 + Owner: id, 136 + }) 137 + } 138 + 139 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 140 + l := s.Logger.With("handler", "dashboard") 141 + 142 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 143 + if !ok { 144 + l.Error("malformed middleware") 145 + w.WriteHeader(http.StatusInternalServerError) 146 + return 147 + } 148 + l = l.With("did", id.DID, "handle", id.Handle) 149 + 150 + all, err := db.GetStrings( 151 + s.Db, 152 + db.FilterEq("did", id.DID), 153 + ) 154 + if err != nil { 155 + l.Error("failed to fetch strings", "err", err) 156 + w.WriteHeader(http.StatusInternalServerError) 157 + return 158 + } 159 + 160 + slices.SortFunc(all, func(a, b db.String) int { 161 + if a.Created.After(b.Created) { 162 + return -1 163 + } else { 164 + return 1 165 + } 166 + }) 167 + 168 + profile, err := db.GetProfile(s.Db, id.DID.String()) 169 + if err != nil { 170 + l.Error("failed to fetch user profile", "err", err) 171 + w.WriteHeader(http.StatusInternalServerError) 172 + return 173 + } 174 + loggedInUser := s.OAuth.GetUser(r) 175 + followStatus := db.IsNotFollowing 176 + if loggedInUser != nil { 177 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 178 + } 179 + 180 + followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 181 + if err != nil { 182 + l.Error("failed to get follow stats", "err", err) 183 + } 184 + 185 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 186 + LoggedInUser: s.OAuth.GetUser(r), 187 + Card: pages.ProfileCard{ 188 + UserDid: id.DID.String(), 189 + UserHandle: id.Handle.String(), 190 + Profile: profile, 191 + FollowStatus: followStatus, 192 + Followers: followers, 193 + Following: following, 194 + }, 195 + Strings: all, 196 + }) 197 + } 198 + 199 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 200 + l := s.Logger.With("handler", "edit") 201 + 202 + user := s.OAuth.GetUser(r) 203 + 204 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 205 + if !ok { 206 + l.Error("malformed middleware") 207 + w.WriteHeader(http.StatusInternalServerError) 208 + return 209 + } 210 + l = l.With("did", id.DID, "handle", id.Handle) 211 + 212 + rkey := chi.URLParam(r, "rkey") 213 + if rkey == "" { 214 + l.Error("malformed url, empty rkey") 215 + w.WriteHeader(http.StatusBadRequest) 216 + return 217 + } 218 + l = l.With("rkey", rkey) 219 + 220 + // get the string currently being edited 221 + all, err := db.GetStrings( 222 + s.Db, 223 + db.FilterEq("did", id.DID), 224 + db.FilterEq("rkey", rkey), 225 + ) 226 + if err != nil { 227 + l.Error("failed to fetch string", "err", err) 228 + w.WriteHeader(http.StatusInternalServerError) 229 + return 230 + } 231 + if len(all) != 1 { 232 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 233 + w.WriteHeader(http.StatusInternalServerError) 234 + return 235 + } 236 + first := all[0] 237 + 238 + // verify that the logged in user owns this string 239 + if user.Did != id.DID.String() { 240 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 241 + w.WriteHeader(http.StatusUnauthorized) 242 + return 243 + } 244 + 245 + switch r.Method { 246 + case http.MethodGet: 247 + // return the form with prefilled fields 248 + s.Pages.PutString(w, pages.PutStringParams{ 249 + LoggedInUser: s.OAuth.GetUser(r), 250 + Action: "edit", 251 + String: first, 252 + }) 253 + case http.MethodPost: 254 + fail := func(msg string, err error) { 255 + l.Error(msg, "err", err) 256 + s.Pages.Notice(w, "error", msg) 257 + } 258 + 259 + filename := r.FormValue("filename") 260 + if filename == "" { 261 + fail("Empty filename.", nil) 262 + return 263 + } 264 + if !strings.Contains(filename, ".") { 265 + // TODO: make this a htmx form validation 266 + fail("No extension provided for filename.", nil) 267 + return 268 + } 269 + 270 + content := r.FormValue("content") 271 + if content == "" { 272 + fail("Empty contents.", nil) 273 + return 274 + } 275 + 276 + description := r.FormValue("description") 277 + 278 + // construct new string from form values 279 + entry := db.String{ 280 + Did: first.Did, 281 + Rkey: first.Rkey, 282 + Filename: filename, 283 + Description: description, 284 + Contents: content, 285 + Created: first.Created, 286 + } 287 + 288 + record := entry.AsRecord() 289 + 290 + client, err := s.OAuth.AuthorizedClient(r) 291 + if err != nil { 292 + fail("Failed to create record.", err) 293 + return 294 + } 295 + 296 + // first replace the existing record in the PDS 297 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 298 + if err != nil { 299 + fail("Failed to updated existing record.", err) 300 + return 301 + } 302 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 303 + Collection: tangled.StringNSID, 304 + Repo: entry.Did.String(), 305 + Rkey: entry.Rkey, 306 + SwapRecord: ex.Cid, 307 + Record: &lexutil.LexiconTypeDecoder{ 308 + Val: &record, 309 + }, 310 + }) 311 + if err != nil { 312 + fail("Failed to updated existing record.", err) 313 + return 314 + } 315 + l := l.With("aturi", resp.Uri) 316 + l.Info("edited string") 317 + 318 + // if that went okay, updated the db 319 + if err = db.AddString(s.Db, entry); err != nil { 320 + fail("Failed to update string.", err) 321 + return 322 + } 323 + 324 + // if that went okay, redir to the string 325 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 326 + } 327 + 328 + } 329 + 330 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 331 + l := s.Logger.With("handler", "create") 332 + user := s.OAuth.GetUser(r) 333 + 334 + switch r.Method { 335 + case http.MethodGet: 336 + s.Pages.PutString(w, pages.PutStringParams{ 337 + LoggedInUser: s.OAuth.GetUser(r), 338 + Action: "new", 339 + }) 340 + case http.MethodPost: 341 + fail := func(msg string, err error) { 342 + l.Error(msg, "err", err) 343 + s.Pages.Notice(w, "error", msg) 344 + } 345 + 346 + filename := r.FormValue("filename") 347 + if filename == "" { 348 + fail("Empty filename.", nil) 349 + return 350 + } 351 + if !strings.Contains(filename, ".") { 352 + // TODO: make this a htmx form validation 353 + fail("No extension provided for filename.", nil) 354 + return 355 + } 356 + 357 + content := r.FormValue("content") 358 + if content == "" { 359 + fail("Empty contents.", nil) 360 + return 361 + } 362 + 363 + description := r.FormValue("description") 364 + 365 + string := db.String{ 366 + Did: syntax.DID(user.Did), 367 + Rkey: tid.TID(), 368 + Filename: filename, 369 + Description: description, 370 + Contents: content, 371 + Created: time.Now(), 372 + } 373 + 374 + record := string.AsRecord() 375 + 376 + client, err := s.OAuth.AuthorizedClient(r) 377 + if err != nil { 378 + fail("Failed to create record.", err) 379 + return 380 + } 381 + 382 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 383 + Collection: tangled.StringNSID, 384 + Repo: user.Did, 385 + Rkey: string.Rkey, 386 + Record: &lexutil.LexiconTypeDecoder{ 387 + Val: &record, 388 + }, 389 + }) 390 + if err != nil { 391 + fail("Failed to create record.", err) 392 + return 393 + } 394 + l := l.With("aturi", resp.Uri) 395 + l.Info("created record") 396 + 397 + // insert into DB 398 + if err = db.AddString(s.Db, string); err != nil { 399 + fail("Failed to create string.", err) 400 + return 401 + } 402 + 403 + // successful 404 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 405 + } 406 + } 407 + 408 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 409 + l := s.Logger.With("handler", "create") 410 + user := s.OAuth.GetUser(r) 411 + fail := func(msg string, err error) { 412 + l.Error(msg, "err", err) 413 + s.Pages.Notice(w, "error", msg) 414 + } 415 + 416 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 417 + if !ok { 418 + l.Error("malformed middleware") 419 + w.WriteHeader(http.StatusInternalServerError) 420 + return 421 + } 422 + l = l.With("did", id.DID, "handle", id.Handle) 423 + 424 + rkey := chi.URLParam(r, "rkey") 425 + if rkey == "" { 426 + l.Error("malformed url, empty rkey") 427 + w.WriteHeader(http.StatusBadRequest) 428 + return 429 + } 430 + 431 + if user.Did != id.DID.String() { 432 + fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 433 + return 434 + } 435 + 436 + if err := db.DeleteString( 437 + s.Db, 438 + db.FilterEq("did", user.Did), 439 + db.FilterEq("rkey", rkey), 440 + ); err != nil { 441 + fail("Failed to delete string.", err) 442 + return 443 + } 444 + 445 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 446 + } 447 + 448 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 449 + }
-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 88 return &out, nil 89 }
··· 87 88 return &out, nil 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 export default { 2 async fetch(request, env) { 3 const url = new URL(request.url); 4 const { pathname, searchParams } = url; 5 ··· 60 const profile = await profileResponse.json(); 61 const avatar = profile.avatar; 62 63 - if (!avatar) { 64 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 65 } 66 67 // Resize if requested 68 let avatarResponse; 69 if (resizeToTiny) { 70 - avatarResponse = await fetch(avatar, { 71 cf: { 72 image: { 73 width: 32, ··· 78 }, 79 }); 80 } else { 81 - avatarResponse = await fetch(avatar); 82 } 83 84 if (!avatarResponse.ok) {
··· 1 export default { 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 + 17 const url = new URL(request.url); 18 const { pathname, searchParams } = url; 19 ··· 74 const profile = await profileResponse.json(); 75 const avatar = profile.avatar; 76 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; 94 } 95 96 // Resize if requested 97 let avatarResponse; 98 if (resizeToTiny) { 99 + avatarResponse = await fetch(avatarUrl, { 100 cf: { 101 image: { 102 width: 32, ··· 107 }, 108 }); 109 } else { 110 + avatarResponse = await fetch(avatarUrl); 111 } 112 113 if (!avatarResponse.ok) {
+2
cmd/gen.go
··· 40 tangled.PublicKey{}, 41 tangled.Repo{}, 42 tangled.RepoArtifact{}, 43 tangled.RepoIssue{}, 44 tangled.RepoIssueComment{}, 45 tangled.RepoIssueState{}, ··· 49 tangled.RepoPullStatus{}, 50 tangled.Spindle{}, 51 tangled.SpindleMember{}, 52 ); err != nil { 53 panic(err) 54 }
··· 40 tangled.PublicKey{}, 41 tangled.Repo{}, 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 44 tangled.RepoIssue{}, 45 tangled.RepoIssueComment{}, 46 tangled.RepoIssueState{}, ··· 50 tangled.RepoPullStatus{}, 51 tangled.Spindle{}, 52 tangled.SpindleMember{}, 53 + tangled.String{}, 54 ); err != nil { 55 panic(err) 56 }
+46 -3
docs/hacking.md
··· 32 nix run .#watch-tailwind 33 ``` 34 35 ## running a knot 36 37 An end-to-end knot setup requires setting up a machine with ··· 39 quite cumbersome. So the nix flake provides a 40 `nixosConfiguration` to do so. 41 42 - To begin, head to `http://localhost:3000` in the browser and 43 - generate a knot secret. Replace the existing secret in 44 - `flake.nix` with the newly generated secret. 45 46 You can now start a lightweight NixOS VM using 47 `nixos-shell` like so: ··· 71 git remote add local-dev git@nixos-shell:user/repo 72 git push local-dev main 73 ```
··· 32 nix run .#watch-tailwind 33 ``` 34 35 + To authenticate with the appview, you will need redis and 36 + OAUTH JWKs to be setup: 37 + 38 + ``` 39 + # oauth jwks should already be setup by the nix devshell: 40 + echo $TANGLED_OAUTH_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 + 51 ## running a knot 52 53 An end-to-end knot setup requires setting up a machine with ··· 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 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. 62 63 You can now start a lightweight NixOS VM using 64 `nixos-shell` like so: ··· 88 git remote add local-dev git@nixos-shell:user/repo 89 git push local-dev main 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 ``` 192 193 Make sure to restart your SSH server!
··· 191 ``` 192 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 14 ### the engine 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. 19 20 The base image for the container is constructed on the fly using 21 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
··· 13 14 ### the engine 15 16 + At present, the only supported backend is Docker (and Podman, if Docker 17 + compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 + executes each step in the pipeline in a fresh container, with state persisted 19 + across steps within the `/tangled/workspace` directory. 20 21 The base image for the container is constructed on the fly using 22 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
+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 depth: 50 58 submodules: true 59 ```
··· 57 depth: 50 58 submodules: true 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.
+22 -22
flake.lock
··· 1 { 2 "nodes": { 3 "gitignore": { 4 "inputs": { 5 "nixpkgs": [ ··· 17 "original": { 18 "owner": "hercules-ci", 19 "repo": "gitignore.nix", 20 - "type": "github" 21 - } 22 - }, 23 - "flake-utils": { 24 - "inputs": { 25 - "systems": "systems" 26 - }, 27 - "locked": { 28 - "lastModified": 1694529238, 29 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 30 - "owner": "numtide", 31 - "repo": "flake-utils", 32 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 33 - "type": "github" 34 - }, 35 - "original": { 36 - "owner": "numtide", 37 - "repo": "flake-utils", 38 "type": "github" 39 } 40 }, ··· 128 "lucide-src": { 129 "flake": false, 130 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 133 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 135 }, 136 "original": { 137 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 139 } 140 }, 141 "nixpkgs": {
··· 1 { 2 "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1694529238, 9 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 "gitignore": { 22 "inputs": { 23 "nixpkgs": [ ··· 35 "original": { 36 "owner": "hercules-ci", 37 "repo": "gitignore.nix", 38 "type": "github" 39 } 40 }, ··· 128 "lucide-src": { 129 "flake": false, 130 "locked": { 131 + "lastModified": 1754044466, 132 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 "type": "tarball", 134 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 }, 136 "original": { 137 "type": "tarball", 138 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 } 140 }, 141 "nixpkgs": {
+28 -4
flake.nix
··· 22 flake = false; 23 }; 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 26 flake = false; 27 }; 28 inter-fonts-src = { ··· 190 }; 191 }); 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;}; 196 nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 197 }; 198 }
··· 22 flake = false; 23 }; 24 lucide-src = { 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 flake = false; 27 }; 28 inter-fonts-src = { ··· 190 }; 191 }); 192 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 + }; 220 nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 221 }; 222 }
+54 -34
go.mod
··· 1 module tangled.sh/tangled.sh/core 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 6 7 require ( 8 github.com/Blank-Xu/sql-adapter v1.1.1 9 github.com/alecthomas/chroma/v2 v2.15.0 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 github.com/cyphar/filepath-securejoin v0.4.1 16 github.com/dgraph-io/ristretto v0.2.0 17 github.com/docker/docker v28.2.2+incompatible ··· 22 github.com/go-git/go-git/v5 v5.14.0 23 github.com/google/uuid v1.6.0 24 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 26 github.com/hiddeco/sshsig v0.2.0 27 github.com/hpcloud/tail v1.0.0 28 github.com/ipfs/go-cid v0.5.0 29 github.com/lestrrat-go/jwx/v2 v2.1.6 30 github.com/mattn/go-sqlite3 v1.14.24 31 github.com/microcosm-cc/bluemonday v1.0.27 32 github.com/posthog/posthog-go v1.5.5 33 - github.com/redis/go-redis/v9 v9.3.0 34 github.com/resend/resend-go/v2 v2.15.0 35 github.com/sethvargo/go-envconfig v1.1.0 36 github.com/stretchr/testify v1.10.0 37 github.com/urfave/cli/v3 v3.3.3 38 github.com/whyrusleeping/cbor-gen v0.3.1 39 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/xerrors v0.0.0-20240903120638-7835f813f4da 43 gopkg.in/yaml.v3 v3.0.1 44 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 45 ) 46 47 require ( 48 dario.cat/mergo v1.0.1 // indirect 49 github.com/Microsoft/go-winio v0.6.2 // indirect 50 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 51 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 52 - github.com/avast/retry-go/v4 v4.6.1 // indirect 53 github.com/aymerick/douceur v0.2.0 // indirect 54 github.com/beorn7/perks v1.0.1 // indirect 55 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 github.com/casbin/govaluate v1.3.0 // indirect 57 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 59 github.com/containerd/errdefs v1.0.0 // indirect 60 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 github.com/containerd/log v0.1.0 // indirect ··· 68 github.com/docker/go-units v0.5.0 // indirect 69 github.com/emirpasic/gods v1.18.1 // indirect 70 github.com/felixge/httpsnoop v1.0.4 // indirect 71 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 github.com/go-git/go-billy/v5 v5.6.2 // indirect 74 - github.com/go-logr/logr v1.4.2 // indirect 75 github.com/go-logr/stdr v1.2.2 // indirect 76 github.com/go-redis/cache/v9 v9.0.0 // indirect 77 github.com/goccy/go-json v0.10.5 // indirect 78 github.com/gogo/protobuf v1.3.2 // indirect 79 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 80 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 81 github.com/gorilla/css v1.0.1 // indirect 82 github.com/gorilla/securecookie v1.1.2 // indirect 83 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 85 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 87 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 90 github.com/ipfs/go-datastore v0.8.2 // indirect 91 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 92 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 95 github.com/ipfs/go-log v1.0.5 // indirect 96 github.com/ipfs/go-log/v2 v2.6.0 // indirect 97 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 98 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 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 102 github.com/lestrrat-go/httpcc v1.0.1 // indirect 103 github.com/lestrrat-go/httprc v1.0.6 // indirect 104 github.com/lestrrat-go/iter v1.0.2 // indirect 105 github.com/lestrrat-go/option v1.0.1 // indirect 106 github.com/mattn/go-isatty v0.0.20 // indirect 107 github.com/minio/sha256-simd v1.0.1 // indirect 108 github.com/moby/docker-image-spec v1.3.1 // indirect 109 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 github.com/moby/term v0.5.2 // indirect ··· 116 github.com/multiformats/go-multihash v0.2.3 // indirect 117 github.com/multiformats/go-varint v0.0.7 // indirect 118 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 119 github.com/opencontainers/go-digest v1.0.0 // indirect 120 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 122 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 github.com/pkg/errors v0.9.1 // indirect 124 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 125 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 126 github.com/prometheus/client_golang v1.22.0 // indirect 127 github.com/prometheus/client_model v0.6.2 // indirect 128 - github.com/prometheus/common v0.63.0 // indirect 129 github.com/prometheus/procfs v0.16.1 // indirect 130 github.com/segmentio/asm v1.2.0 // indirect 131 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 136 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 137 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 138 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 143 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 144 go.uber.org/atomic v1.11.0 // indirect 145 go.uber.org/multierr v1.11.0 // indirect 146 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 154 google.golang.org/protobuf v1.36.6 // indirect 155 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
··· 1 module tangled.sh/tangled.sh/core 2 3 + go 1.24.4 4 5 require ( 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 8 github.com/alecthomas/chroma/v2 v2.15.0 9 + github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 16 github.com/cyphar/filepath-securejoin v0.4.1 17 github.com/dgraph-io/ristretto v0.2.0 18 github.com/docker/docker v28.2.2+incompatible ··· 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 github.com/gorilla/sessions v1.4.0 26 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 27 github.com/hiddeco/sshsig v0.2.0 28 github.com/hpcloud/tail v1.0.0 29 github.com/ipfs/go-cid v0.5.0 30 github.com/lestrrat-go/jwx/v2 v2.1.6 31 github.com/mattn/go-sqlite3 v1.14.24 32 github.com/microcosm-cc/bluemonday v1.0.27 33 + github.com/openbao/openbao/api/v2 v2.3.0 34 github.com/posthog/posthog-go v1.5.5 35 + github.com/redis/go-redis/v9 v9.7.3 36 github.com/resend/resend-go/v2 v2.15.0 37 github.com/sethvargo/go-envconfig v1.1.0 38 github.com/stretchr/testify v1.10.0 39 github.com/urfave/cli/v3 v3.3.3 40 github.com/whyrusleeping/cbor-gen v0.3.1 41 github.com/yuin/goldmark v1.4.13 42 + golang.org/x/crypto v0.40.0 43 + golang.org/x/net v0.42.0 44 + golang.org/x/sync v0.16.0 45 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 46 gopkg.in/yaml.v3 v3.0.1 47 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 48 ) 49 50 require ( 51 dario.cat/mergo v1.0.1 // indirect 52 github.com/Microsoft/go-winio v0.6.2 // indirect 53 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 54 + github.com/alecthomas/repr v0.4.0 // indirect 55 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 56 github.com/aymerick/douceur v0.2.0 // indirect 57 github.com/beorn7/perks v1.0.1 // indirect 58 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 59 github.com/casbin/govaluate v1.3.0 // indirect 60 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 61 github.com/cespare/xxhash/v2 v2.3.0 // indirect 62 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 63 github.com/containerd/errdefs v1.0.0 // indirect 64 github.com/containerd/errdefs/pkg v0.3.0 // indirect 65 github.com/containerd/log v0.1.0 // indirect ··· 72 github.com/docker/go-units v0.5.0 // indirect 73 github.com/emirpasic/gods v1.18.1 // indirect 74 github.com/felixge/httpsnoop v1.0.4 // indirect 75 + github.com/fsnotify/fsnotify v1.6.0 // indirect 76 github.com/go-enry/go-oniguruma v1.2.1 // indirect 77 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 78 github.com/go-git/go-billy/v5 v5.6.2 // indirect 79 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 80 + github.com/go-logr/logr v1.4.3 // indirect 81 github.com/go-logr/stdr v1.2.2 // indirect 82 github.com/go-redis/cache/v9 v9.0.0 // indirect 83 + github.com/go-test/deep v1.1.1 // indirect 84 github.com/goccy/go-json v0.10.5 // indirect 85 github.com/gogo/protobuf v1.3.2 // indirect 86 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 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 90 github.com/gorilla/css v1.0.1 // indirect 91 github.com/gorilla/securecookie v1.1.2 // indirect 92 + github.com/hashicorp/errwrap v1.1.0 // indirect 93 github.com/hashicorp/go-cleanhttp v0.5.2 // 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 99 github.com/hashicorp/golang-lru v1.0.2 // indirect 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 103 github.com/ipfs/bbloom v0.0.4 // indirect 104 + github.com/ipfs/boxo v0.33.0 // indirect 105 + github.com/ipfs/go-block-format v0.2.2 // indirect 106 github.com/ipfs/go-datastore v0.8.2 // indirect 107 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 108 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 109 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 110 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 111 github.com/ipfs/go-log v1.0.5 // indirect 112 github.com/ipfs/go-log/v2 v2.6.0 // indirect 113 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 114 github.com/kevinburke/ssh_config v1.2.0 // indirect 115 github.com/klauspost/compress v1.18.0 // indirect 116 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 117 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 118 github.com/lestrrat-go/httpcc v1.0.1 // indirect 119 github.com/lestrrat-go/httprc v1.0.6 // indirect 120 github.com/lestrrat-go/iter v1.0.2 // indirect 121 github.com/lestrrat-go/option v1.0.1 // indirect 122 github.com/mattn/go-isatty v0.0.20 // indirect 123 github.com/minio/sha256-simd v1.0.1 // indirect 124 + github.com/mitchellh/mapstructure v1.5.0 // indirect 125 github.com/moby/docker-image-spec v1.3.1 // indirect 126 github.com/moby/sys/atomicwriter v0.1.0 // indirect 127 github.com/moby/term v0.5.2 // indirect ··· 133 github.com/multiformats/go-multihash v0.2.3 // indirect 134 github.com/multiformats/go-varint v0.0.7 // indirect 135 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 136 + github.com/onsi/gomega v1.37.0 // indirect 137 github.com/opencontainers/go-digest v1.0.0 // indirect 138 github.com/opencontainers/image-spec v1.1.1 // indirect 139 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 140 github.com/pjbgf/sha1cd v0.3.2 // indirect 141 github.com/pkg/errors v0.9.1 // indirect 142 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 143 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 144 github.com/prometheus/client_golang v1.22.0 // indirect 145 github.com/prometheus/client_model v0.6.2 // indirect 146 + github.com/prometheus/common v0.64.0 // indirect 147 github.com/prometheus/procfs v0.16.1 // indirect 148 + github.com/ryanuber/go-glob v1.0.0 // indirect 149 github.com/segmentio/asm v1.2.0 // indirect 150 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 151 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 155 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 156 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 157 go.opentelemetry.io/auto/sdk v1.1.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 163 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 164 go.uber.org/atomic v1.11.0 // indirect 165 go.uber.org/multierr v1.11.0 // indirect 166 go.uber.org/zap v1.27.0 // 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 174 google.golang.org/protobuf v1.36.6 // indirect 175 gopkg.in/fsnotify.v1 v1.4.7 // indirect 176 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129 -87
go.sum
··· 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 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= 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 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= 56 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 91 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 92 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 93 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 97 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 98 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 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 101 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 102 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 103 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 114 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 115 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 116 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 117 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 118 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= 121 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 122 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 123 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 124 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 125 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 126 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 127 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 128 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 129 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 130 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= 133 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 134 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 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 137 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 138 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 139 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 146 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 154 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 166 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 167 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 168 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= 171 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 172 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 173 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 174 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 175 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 176 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= 179 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 180 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 181 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 182 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 183 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 184 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 185 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 189 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 190 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 191 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= 196 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 197 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 198 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 205 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 206 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 207 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= 212 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 213 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 214 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 216 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 217 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 218 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 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 222 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 223 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 229 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 230 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 231 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= 234 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 235 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 236 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 239 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 240 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 241 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= 244 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 245 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 246 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 251 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 252 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 253 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= 260 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 261 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 262 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 265 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 267 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 268 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 269 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 270 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 281 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 282 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 283 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 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 287 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 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 318 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 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= 323 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 324 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 325 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 326 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 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 329 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 330 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 331 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 346 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 347 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 348 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= 351 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 352 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 353 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= 356 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 357 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 358 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 360 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 361 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 362 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 363 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 364 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 365 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 404 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 405 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 406 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 407 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 408 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 409 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 413 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 414 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 415 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= 422 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 423 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= 432 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 433 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 434 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 451 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 452 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 453 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= 458 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 459 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 460 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 461 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 462 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 463 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 464 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 465 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 466 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 467 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 468 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 471 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 472 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 473 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 474 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 475 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 476 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 480 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 481 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 482 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= 485 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 486 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 489 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 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= 494 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 495 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 496 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 502 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 505 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 506 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 510 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 517 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= 520 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 521 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 522 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 523 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 524 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 525 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= 528 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 529 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 530 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 532 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 533 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 534 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= 539 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 547 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 548 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 549 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 550 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 551 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 552 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 553 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 554 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 556 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 557 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 558 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 559 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= 566 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 567 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 568 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 599 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 600 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 601 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= 604 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 605 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 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= 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 93 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 94 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 95 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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= 98 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 99 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 100 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 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= 104 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 105 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 106 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 117 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 118 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 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= 122 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 123 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 125 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 126 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 127 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 128 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 129 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 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= 133 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 134 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 135 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 136 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 137 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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= 140 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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= 145 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 146 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 147 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 154 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 155 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 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= 158 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 159 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 160 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 161 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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= 165 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 166 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 167 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 177 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 178 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 179 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 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= 182 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 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= 187 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 188 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 189 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 190 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 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= 201 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 202 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 203 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 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= 207 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 208 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 209 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 213 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 214 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 215 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 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= 220 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 221 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 222 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 229 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 230 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 231 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 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= 236 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 237 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 238 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 240 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 241 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 242 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 243 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 244 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 245 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 251 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 252 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 253 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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= 256 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 257 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 258 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 261 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 262 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 263 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= 266 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 267 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 268 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 273 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 274 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 275 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 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= 278 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 279 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 280 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 283 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 284 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 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= 288 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 289 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 290 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 301 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 302 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 303 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 304 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 305 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 306 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 307 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 308 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 334 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 335 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 336 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 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= 341 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 342 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 343 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 344 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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= 348 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 349 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 350 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 365 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 366 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 367 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 368 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 369 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 370 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 371 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 372 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 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= 375 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 376 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 377 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 379 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 380 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= 384 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 385 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 386 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 425 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 426 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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= 429 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 430 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 431 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 435 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 436 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 437 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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= 444 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 445 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= 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= 454 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 455 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 456 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 473 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 474 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 475 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 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= 481 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 482 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 483 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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= 486 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 487 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 488 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 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= 491 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 492 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 493 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 496 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 497 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 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= 500 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 501 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 502 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 506 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 507 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 508 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 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= 513 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 514 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 515 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 517 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 518 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 519 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 520 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 521 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 522 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 523 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 524 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 530 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 531 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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= 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= 536 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 537 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 538 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 540 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 541 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= 544 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 545 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 546 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= 549 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= 554 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 555 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 556 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 557 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 558 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 559 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 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= 565 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 566 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 567 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 569 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 570 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 571 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 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= 579 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 580 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 581 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 587 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 588 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 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= 591 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 592 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 593 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 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= 596 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 597 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 598 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 599 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 600 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 601 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 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= 608 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 609 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 610 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 641 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 642 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 643 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 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= 646 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 647 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20 -4
guard/guard.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "net/url" ··· 13 "github.com/bluesky-social/indigo/atproto/identity" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/urfave/cli/v3" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/core/log" 18 ) 19 ··· 43 Usage: "internal API endpoint", 44 Value: "http://localhost:5444", 45 }, 46 }, 47 } 48 } ··· 54 gitDir := cmd.String("git-dir") 55 logPath := cmd.String("log-path") 56 endpoint := cmd.String("internal-api") 57 58 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 if err != nil { ··· 149 "fullPath", fullPath, 150 "client", clientIP) 151 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 154 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 156 } 157 158 gitCmd := exec.Command(gitCommand, fullPath) 159 gitCmd.Stdout = os.Stdout
··· 2 3 import ( 4 "context" 5 + "errors" 6 "fmt" 7 + "io" 8 "log/slog" 9 "net/http" 10 "net/url" ··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/log" 20 ) 21 ··· 45 Usage: "internal API endpoint", 46 Value: "http://localhost:5444", 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 53 }, 54 } 55 } ··· 61 gitDir := cmd.String("git-dir") 62 logPath := cmd.String("log-path") 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 65 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 67 if err != nil { ··· 157 "fullPath", fullPath, 158 "client", clientIP) 159 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") 166 } else { 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 171 } 172 + io.Copy(os.Stderr, motdReader) 173 174 gitCmd := exec.Command(gitCommand, fullPath) 175 gitCmd.Stdout = os.Stdout
+24
hook/hook.go
··· 3 import ( 4 "bufio" 5 "context" 6 "fmt" 7 "net/http" 8 "os" ··· 10 11 "github.com/urfave/cli/v3" 12 ) 13 14 // The hook command is nested like so: 15 // ··· 36 Usage: "endpoint for the internal API", 37 Value: "http://localhost:5444", 38 }, 39 }, 40 Commands: []*cli.Command{ 41 { ··· 52 userDid := cmd.String("user-did") 53 userHandle := cmd.String("user-handle") 54 endpoint := cmd.String("internal-api") 55 56 payloadReader := bufio.NewReader(os.Stdin) 57 payload, _ := payloadReader.ReadString('\n') ··· 67 req.Header.Set("X-Git-Dir", gitDir) 68 req.Header.Set("X-Git-User-Did", userDid) 69 req.Header.Set("X-Git-User-Handle", userHandle) 70 71 resp, err := client.Do(req) 72 if err != nil { ··· 76 77 if resp.StatusCode != http.StatusOK { 78 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 79 } 80 81 return nil
··· 3 import ( 4 "bufio" 5 "context" 6 + "encoding/json" 7 "fmt" 8 "net/http" 9 "os" ··· 11 12 "github.com/urfave/cli/v3" 13 ) 14 + 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 18 19 // The hook command is nested like so: 20 // ··· 41 Usage: "endpoint for the internal API", 42 Value: "http://localhost:5444", 43 }, 44 + &cli.StringSliceFlag{ 45 + Name: "push-option", 46 + Usage: "any push option from git", 47 + }, 48 }, 49 Commands: []*cli.Command{ 50 { ··· 61 userDid := cmd.String("user-did") 62 userHandle := cmd.String("user-handle") 63 endpoint := cmd.String("internal-api") 64 + pushOptions := cmd.StringSlice("push-option") 65 66 payloadReader := bufio.NewReader(os.Stdin) 67 payload, _ := payloadReader.ReadString('\n') ··· 77 req.Header.Set("X-Git-Dir", gitDir) 78 req.Header.Set("X-Git-User-Did", userDid) 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 + } 85 86 resp, err := client.Do(req) 87 if err != nil { ··· 91 92 if resp.StatusCode != http.StatusOK { 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) 103 } 104 105 return nil
+6 -1
hook/setup.go
··· 133 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 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 137 `, executablePath, config.internalApi) 138 139 return os.WriteFile(hookPath, []byte(hookContent), 0755)
··· 133 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 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 142 `, executablePath, config.internalApi) 143 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 101 .prose img { 102 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 105 vertical-align: middle; 106 } 107 }
··· 100 101 .prose img { 102 display: inline; 103 + margin: 0; 104 vertical-align: middle; 105 } 106 }
+13
jetstream/jetstream.go
··· 52 j.mu.Unlock() 53 } 54 55 type processor func(context.Context, *models.Event) error 56 57 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
··· 52 j.mu.Unlock() 53 } 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 + 68 type processor func(context.Context, *models.Event) error 69 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
··· 2 3 import ( 4 "context" 5 6 "github.com/sethvargo/go-envconfig" 7 ) 8 ··· 23 24 // This disables signature verification so use with caution. 25 Dev bool `env:"DEV, default=false"` 26 } 27 28 type Config struct {
··· 2 3 import ( 4 "context" 5 + "fmt" 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/sethvargo/go-envconfig" 9 ) 10 ··· 25 26 // This disables signature verification so use with caution. 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)) 32 } 33 34 type Config struct {
-8
knotserver/file.go
··· 10 "tangled.sh/tangled.sh/core/types" 11 ) 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 func countLines(r io.Reader) (int, error) { 21 buf := make([]byte, 32*1024) 22 bufLen := 0 ··· 52 53 resp.Lines = lc 54 writeJSON(w, resp) 55 - return 56 }
··· 10 "tangled.sh/tangled.sh/core/types" 11 ) 12 13 func countLines(r io.Reader) (int, error) { 14 buf := make([]byte, 32*1024) 15 bufLen := 0 ··· 45 46 resp.Lines = lc 47 writeJSON(w, resp) 48 }
+37 -18
knotserver/handler.go
··· 8 "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 "tangled.sh/tangled.sh/core/jetstream" 12 "tangled.sh/tangled.sh/core/knotserver/config" 13 "tangled.sh/tangled.sh/core/knotserver/db" 14 "tangled.sh/tangled.sh/core/notifier" 15 "tangled.sh/tangled.sh/core/rbac" 16 - ) 17 - 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 ) 21 22 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 29 30 // init is a channel that is closed when the knot has been initailized 31 // i.e. when the first user (knot owner) has been added. ··· 37 r := chi.NewRouter() 38 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{}), 47 } 48 49 - err := e.AddKnot(ThisServer) 50 if err != nil { 51 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 52 } ··· 131 }) 132 }) 133 134 // Create a new repository. 135 r.Route("/repo", func(r chi.Router) { 136 r.Use(h.VerifySignature) ··· 161 r.Get("/keys", h.Keys) 162 163 return r, nil 164 } 165 166 // version is set during build time.
··· 8 "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 "tangled.sh/tangled.sh/core/jetstream" 13 "tangled.sh/tangled.sh/core/knotserver/config" 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 "tangled.sh/tangled.sh/core/notifier" 18 "tangled.sh/tangled.sh/core/rbac" 19 ) 20 21 type Handle struct { 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 30 // init is a channel that is closed when the knot has been initailized 31 // i.e. when the first user (knot owner) has been added. ··· 37 r := chi.NewRouter() 38 39 h := Handle{ 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{}), 48 } 49 50 + err := e.AddKnot(rbac.ThisServer) 51 if err != nil { 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 } ··· 132 }) 133 }) 134 135 + // xrpc apis 136 + r.Mount("/xrpc", h.XrpcRouter()) 137 + 138 // Create a new repository. 139 r.Route("/repo", func(r chi.Router) { 140 r.Use(h.VerifySignature) ··· 165 r.Get("/keys", h.Keys) 166 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() 183 } 184 185 // version is set during build time.
+65 -4
knotserver/ingester.go
··· 17 "github.com/bluesky-social/jetstream/pkg/models" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 "tangled.sh/tangled.sh/core/log" 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 ··· 46 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 47 } 48 49 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 50 if err != nil || !ok { 51 l.Error("failed to add member", "did", did) 52 return fmt.Errorf("failed to enforce permissions: %w", err) 53 } 54 55 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 56 l.Error("failed to add member", "error", err) 57 return fmt.Errorf("failed to add member: %w", err) 58 } ··· 212 return h.db.InsertEvent(event, h.n) 213 } 214 215 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 216 l := log.FromContext(ctx) 217 ··· 265 defer func() { 266 eventTime := event.TimeUS 267 lastTimeUs := eventTime + 1 268 - fmt.Println("lastTimeUs", lastTimeUs) 269 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 270 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 271 } ··· 291 if err := h.processKnotMember(ctx, did, record); err != nil { 292 return fmt.Errorf("failed to process knot member: %w", err) 293 } 294 case tangled.RepoPullNSID: 295 var record tangled.RepoPull 296 if err := json.Unmarshal(raw, &record); err != nil { ··· 299 if err := h.processPull(ctx, did, record); err != nil { 300 return fmt.Errorf("failed to process knot member: %w", err) 301 } 302 } 303 304 return err
··· 17 "github.com/bluesky-social/jetstream/pkg/models" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/rbac" 25 "tangled.sh/tangled.sh/core/workflow" 26 ) 27 ··· 47 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 48 } 49 50 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 51 if err != nil || !ok { 52 l.Error("failed to add member", "did", did) 53 return fmt.Errorf("failed to enforce permissions: %w", err) 54 } 55 56 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 57 l.Error("failed to add member", "error", err) 58 return fmt.Errorf("failed to add member: %w", err) 59 } ··· 213 return h.db.InsertEvent(event, h.n) 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 + 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 267 l := log.FromContext(ctx) 268 ··· 316 defer func() { 317 eventTime := event.TimeUS 318 lastTimeUs := eventTime + 1 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 321 } ··· 341 if err := h.processKnotMember(ctx, did, record); err != nil { 342 return fmt.Errorf("failed to process knot member: %w", err) 343 } 344 + 345 case tangled.RepoPullNSID: 346 var record tangled.RepoPull 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 350 if err := h.processPull(ctx, did, record); err != nil { 351 return fmt.Errorf("failed to process knot member: %w", err) 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 + 363 } 364 365 return err
+62 -5
knotserver/internal.go
··· 13 "github.com/go-chi/chi/v5" 14 "github.com/go-chi/chi/v5/middleware" 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/knotserver/config" 17 "tangled.sh/tangled.sh/core/knotserver/db" 18 "tangled.sh/tangled.sh/core/knotserver/git" ··· 38 return 39 } 40 41 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 42 if err != nil || !ok { 43 w.WriteHeader(http.StatusForbidden) 44 return ··· 64 return 65 } 66 67 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 68 l := h.l.With("handler", "PostReceiveHook") 69 ··· 90 // non-fatal 91 } 92 93 for _, line := range lines { 94 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 95 if err != nil { ··· 97 // non-fatal 98 } 99 100 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 101 if err != nil { 102 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 103 // non-fatal 104 } 105 } 106 } 107 108 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 148 return h.db.InsertEvent(event, h.n) 149 } 150 151 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 152 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 153 if err != nil { 154 return err ··· 169 return err 170 } 171 172 var pipeline workflow.Pipeline 173 for _, e := range workflowDir { 174 if !e.IsFile { ··· 183 184 wf, err := workflow.FromFile(e.Name, contents) 185 if err != nil { 186 - // TODO: log here, respond to client that is pushing 187 h.l.Error("failed to parse workflow", "err", err, "path", fpath) 188 continue 189 } 190 ··· 209 }, 210 } 211 212 - // TODO: send the diagnostics back to the user here via stderr 213 cp := compiler.Compile(pipeline) 214 eventJson, err := json.Marshal(cp) 215 if err != nil { 216 return err 217 } 218 219 // do not run empty pipelines
··· 13 "github.com/go-chi/chi/v5" 14 "github.com/go-chi/chi/v5/middleware" 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/hook" 17 "tangled.sh/tangled.sh/core/knotserver/config" 18 "tangled.sh/tangled.sh/core/knotserver/db" 19 "tangled.sh/tangled.sh/core/knotserver/git" ··· 39 return 40 } 41 42 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 43 if err != nil || !ok { 44 w.WriteHeader(http.StatusForbidden) 45 return ··· 65 return 66 } 67 68 + type PushOptions struct { 69 + skipCi bool 70 + verboseCi bool 71 + } 72 + 73 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 74 l := h.l.With("handler", "PostReceiveHook") 75 ··· 96 // non-fatal 97 } 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 + 115 for _, line := range lines { 116 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 117 if err != nil { ··· 119 // non-fatal 120 } 121 122 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 123 if err != nil { 124 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 125 // non-fatal 126 } 127 } 128 + 129 + writeJSON(w, resp) 130 } 131 132 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 172 return h.db.InsertEvent(event, h.n) 173 } 174 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 + 180 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 181 if err != nil { 182 return err ··· 197 return err 198 } 199 200 + pipelineParseErrors := []string{} 201 + 202 var pipeline workflow.Pipeline 203 for _, e := range workflowDir { 204 if !e.IsFile { ··· 213 214 wf, err := workflow.FromFile(e.Name, contents) 215 if err != nil { 216 h.l.Error("failed to parse workflow", "err", err, "path", fpath) 217 + pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 218 continue 219 } 220 ··· 239 }, 240 } 241 242 cp := compiler.Compile(pipeline) 243 eventJson, err := json.Marshal(cp) 244 if err != nil { 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 + } 274 } 275 276 // do not run empty pipelines
+17 -8
knotserver/routes.go
··· 29 "tangled.sh/tangled.sh/core/knotserver/db" 30 "tangled.sh/tangled.sh/core/knotserver/git" 31 "tangled.sh/tangled.sh/core/patchutil" 32 "tangled.sh/tangled.sh/core/types" 33 ) 34 ··· 285 mimeType = "image/svg+xml" 286 } 287 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) 291 return 292 } 293 ··· 674 } 675 676 // add perms for this user to access the repo 677 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 678 if err != nil { 679 l.Error("adding repo permissions", "error", err.Error()) 680 writeError(w, err.Error(), http.StatusInternalServerError) ··· 892 } 893 894 // add perms for this user to access the repo 895 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 896 if err != nil { 897 l.Error("adding repo permissions", "error", err.Error()) 898 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1146 } 1147 h.jc.AddDid(did) 1148 1149 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1150 l.Error("adding member", "error", err.Error()) 1151 writeError(w, err.Error(), http.StatusInternalServerError) 1152 return ··· 1184 h.jc.AddDid(data.Did) 1185 1186 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1187 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1188 l.Error("adding repo collaborator", "error", err.Error()) 1189 writeError(w, err.Error(), http.StatusInternalServerError) 1190 return ··· 1281 } 1282 h.jc.AddDid(data.Did) 1283 1284 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1285 l.Error("adding owner", "error", err.Error()) 1286 writeError(w, err.Error(), http.StatusInternalServerError) 1287 return
··· 29 "tangled.sh/tangled.sh/core/knotserver/db" 30 "tangled.sh/tangled.sh/core/knotserver/git" 31 "tangled.sh/tangled.sh/core/patchutil" 32 + "tangled.sh/tangled.sh/core/rbac" 33 "tangled.sh/tangled.sh/core/types" 34 ) 35 ··· 286 mimeType = "image/svg+xml" 287 } 288 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) 300 return 301 } 302 ··· 683 } 684 685 // add perms for this user to access the repo 686 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 687 if err != nil { 688 l.Error("adding repo permissions", "error", err.Error()) 689 writeError(w, err.Error(), http.StatusInternalServerError) ··· 901 } 902 903 // add perms for this user to access the repo 904 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 905 if err != nil { 906 l.Error("adding repo permissions", "error", err.Error()) 907 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1155 } 1156 h.jc.AddDid(did) 1157 1158 + if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1159 l.Error("adding member", "error", err.Error()) 1160 writeError(w, err.Error(), http.StatusInternalServerError) 1161 return ··· 1193 h.jc.AddDid(data.Did) 1194 1195 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1196 + if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1197 l.Error("adding repo collaborator", "error", err.Error()) 1198 writeError(w, err.Error(), http.StatusInternalServerError) 1199 return ··· 1290 } 1291 h.jc.AddDid(data.Did) 1292 1293 + if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1294 l.Error("adding owner", "error", err.Error()) 1295 writeError(w, err.Error(), http.StatusInternalServerError) 1296 return
+1
knotserver/server.go
··· 76 tangled.PublicKeyNSID, 77 tangled.KnotMemberNSID, 78 tangled.RepoPullNSID, 79 }, nil, logger, db, true, c.Server.LogDids) 80 if err != nil { 81 logger.Error("failed to setup jetstream", "error", err)
··· 76 tangled.PublicKeyNSID, 77 tangled.KnotMemberNSID, 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 80 }, nil, logger, db, true, c.Server.LogDids) 81 if err != nil { 82 logger.Error("failed to setup jetstream", "error", err)
-5
knotserver/util.go
··· 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "github.com/go-chi/chi/v5" 11 - "github.com/microcosm-cc/bluemonday" 12 ) 13 - 14 - func sanitize(content []byte) []byte { 15 - return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 16 - } 17 18 func didPath(r *http.Request) string { 19 did := chi.URLParam(r, "did")
··· 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "github.com/go-chi/chi/v5" 11 ) 12 13 func didPath(r *http.Request) string { 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 -
···
+40
lexicons/string/string.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+119 -59
nix/gomod2nix.toml
··· 11 version = "v0.6.2" 12 hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 [mod."github.com/ProtonMail/go-crypto"] 14 - version = "v1.2.0" 15 - hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo=" 16 [mod."github.com/alecthomas/chroma/v2"] 17 version = "v2.19.0" 18 hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 19 replaced = "github.com/oppiliappan/chroma/v2" 20 [mod."github.com/anmitsu/go-shlex"] 21 version = "v0.0.0-20200514113438-38f4b401e2be" 22 hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" ··· 34 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 35 replaced = "tangled.sh/oppi.li/go-gitdiff" 36 [mod."github.com/bluesky-social/indigo"] 37 - version = "v0.0.0-20250520232546-236dd575c91e" 38 - hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0=" 39 [mod."github.com/bluesky-social/jetstream"] 40 version = "v0.0.0-20241210005130-ea96859b93d1" 41 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 51 [mod."github.com/casbin/govaluate"] 52 version = "v1.3.0" 53 hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 54 [mod."github.com/cespare/xxhash/v2"] 55 version = "v2.3.0" 56 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 57 [mod."github.com/cloudflare/circl"] 58 - version = "v1.6.0" 59 - hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0=" 60 [mod."github.com/containerd/errdefs"] 61 version = "v1.0.0" 62 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 105 [mod."github.com/felixge/httpsnoop"] 106 version = "v1.0.4" 107 hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 108 [mod."github.com/gliderlabs/ssh"] 109 version = "v0.3.8" 110 hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" ··· 127 version = "v5.17.0" 128 hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 129 replaced = "github.com/oppiliappan/go-git/v5" 130 [mod."github.com/go-logr/logr"] 131 - version = "v1.4.2" 132 - hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI=" 133 [mod."github.com/go-logr/stdr"] 134 version = "v1.2.2" 135 hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 136 [mod."github.com/go-redis/cache/v9"] 137 version = "v9.0.0" 138 hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 139 [mod."github.com/goccy/go-json"] 140 version = "v0.10.5" 141 hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" ··· 143 version = "v1.3.2" 144 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 145 [mod."github.com/golang-jwt/jwt/v5"] 146 - version = "v5.2.2" 147 - hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4=" 148 [mod."github.com/golang/groupcache"] 149 version = "v0.0.0-20241129210726-2c02b8208cf8" 150 hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 151 [mod."github.com/google/uuid"] 152 version = "v1.6.0" 153 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" ··· 161 version = "v1.4.0" 162 hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 163 [mod."github.com/gorilla/websocket"] 164 - version = "v1.5.3" 165 - hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0=" 166 [mod."github.com/hashicorp/go-cleanhttp"] 167 version = "v0.5.2" 168 hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 169 [mod."github.com/hashicorp/go-retryablehttp"] 170 - version = "v0.7.7" 171 - hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU=" 172 [mod."github.com/hashicorp/golang-lru"] 173 version = "v1.0.2" 174 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 175 [mod."github.com/hashicorp/golang-lru/v2"] 176 version = "v2.0.7" 177 hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 178 [mod."github.com/hiddeco/sshsig"] 179 version = "v0.2.0" 180 hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" ··· 185 version = "v0.0.4" 186 hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 187 [mod."github.com/ipfs/boxo"] 188 - version = "v0.30.0" 189 - hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848=" 190 [mod."github.com/ipfs/go-block-format"] 191 - version = "v0.2.1" 192 - hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8=" 193 [mod."github.com/ipfs/go-cid"] 194 version = "v0.5.0" 195 hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" ··· 203 version = "v1.1.1" 204 hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 205 [mod."github.com/ipfs/go-ipld-cbor"] 206 - version = "v0.2.0" 207 - hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc=" 208 [mod."github.com/ipfs/go-ipld-format"] 209 - version = "v0.6.1" 210 - hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4=" 211 [mod."github.com/ipfs/go-log"] 212 version = "v1.0.5" 213 hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" ··· 224 version = "v1.18.0" 225 hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 226 [mod."github.com/klauspost/cpuid/v2"] 227 - version = "v2.2.10" 228 - hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0=" 229 [mod."github.com/lestrrat-go/blackmagic"] 230 - version = "v1.0.3" 231 - hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw=" 232 [mod."github.com/lestrrat-go/httpcc"] 233 version = "v1.0.1" 234 hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" ··· 256 [mod."github.com/minio/sha256-simd"] 257 version = "v1.0.1" 258 hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 259 [mod."github.com/moby/docker-image-spec"] 260 version = "v1.3.1" 261 hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" ··· 289 [mod."github.com/munnerz/goautoneg"] 290 version = "v0.0.0-20191010083416-a7dc8b61c822" 291 hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 292 [mod."github.com/opencontainers/go-digest"] 293 version = "v1.0.0" 294 hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" ··· 296 version = "v1.1.1" 297 hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 298 [mod."github.com/opentracing/opentracing-go"] 299 - version = "v1.2.0" 300 - hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM=" 301 [mod."github.com/pjbgf/sha1cd"] 302 version = "v0.3.2" 303 hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" ··· 320 version = "v0.6.2" 321 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 322 [mod."github.com/prometheus/common"] 323 - version = "v0.63.0" 324 - hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE=" 325 [mod."github.com/prometheus/procfs"] 326 version = "v0.16.1" 327 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 328 [mod."github.com/redis/go-redis/v9"] 329 - version = "v9.3.0" 330 - hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w=" 331 [mod."github.com/resend/resend-go/v2"] 332 version = "v2.15.0" 333 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 334 [mod."github.com/segmentio/asm"] 335 version = "v1.2.0" 336 hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" ··· 375 version = "v1.1.0" 376 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 377 [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 378 - version = "v0.61.0" 379 - hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM=" 380 [mod."go.opentelemetry.io/otel"] 381 - version = "v1.36.0" 382 - hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko=" 383 [mod."go.opentelemetry.io/otel/metric"] 384 - version = "v1.36.0" 385 - hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8=" 386 [mod."go.opentelemetry.io/otel/trace"] 387 - version = "v1.36.0" 388 - hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA=" 389 [mod."go.opentelemetry.io/proto/otlp"] 390 version = "v1.6.0" 391 hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" ··· 399 version = "v1.27.0" 400 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 401 [mod."golang.org/x/crypto"] 402 - version = "v0.38.0" 403 - hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY=" 404 [mod."golang.org/x/exp"] 405 - version = "v0.0.0-20250408133849-7e4ce0ab07d0" 406 - hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8=" 407 [mod."golang.org/x/net"] 408 - version = "v0.40.0" 409 - hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8=" 410 [mod."golang.org/x/sync"] 411 - version = "v0.14.0" 412 - hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4=" 413 [mod."golang.org/x/sys"] 414 - version = "v0.33.0" 415 - hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ=" 416 [mod."golang.org/x/time"] 417 - version = "v0.8.0" 418 - hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ=" 419 [mod."golang.org/x/xerrors"] 420 version = "v0.0.0-20240903120638-7835f813f4da" 421 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 422 [mod."google.golang.org/genproto/googleapis/api"] 423 - version = "v0.0.0-20250519155744-55703ea1f237" 424 - hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ=" 425 [mod."google.golang.org/genproto/googleapis/rpc"] 426 - version = "v0.0.0-20250519155744-55703ea1f237" 427 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 428 [mod."google.golang.org/grpc"] 429 - version = "v1.72.1" 430 - hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs=" 431 [mod."google.golang.org/protobuf"] 432 version = "v1.36.6" 433 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" ··· 450 version = "v1.4.1" 451 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 452 [mod."tangled.sh/icyphox.sh/atproto-oauth"] 453 - version = "v0.0.0-20250526154904-3906c5336421" 454 - hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM="
··· 11 version = "v0.6.2" 12 hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 [mod."github.com/ProtonMail/go-crypto"] 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=" 19 [mod."github.com/alecthomas/chroma/v2"] 20 version = "v2.19.0" 21 hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 22 replaced = "github.com/oppiliappan/chroma/v2" 23 + [mod."github.com/alecthomas/repr"] 24 + version = "v0.4.0" 25 + hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU=" 26 [mod."github.com/anmitsu/go-shlex"] 27 version = "v0.0.0-20200514113438-38f4b401e2be" 28 hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" ··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 57 [mod."github.com/casbin/govaluate"] 58 version = "v1.3.0" 59 hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 60 + [mod."github.com/cenkalti/backoff/v4"] 61 + version = "v4.3.0" 62 + hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8=" 63 [mod."github.com/cespare/xxhash/v2"] 64 version = "v2.3.0" 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 66 [mod."github.com/cloudflare/circl"] 67 + version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 + hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 [mod."github.com/containerd/errdefs"] 70 version = "v1.0.0" 71 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 114 [mod."github.com/felixge/httpsnoop"] 115 version = "v1.0.4" 116 hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 117 + [mod."github.com/fsnotify/fsnotify"] 118 + version = "v1.6.0" 119 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 120 [mod."github.com/gliderlabs/ssh"] 121 version = "v0.3.8" 122 hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" ··· 139 version = "v5.17.0" 140 hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 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=" 145 [mod."github.com/go-logr/logr"] 146 + version = "v1.4.3" 147 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 148 [mod."github.com/go-logr/stdr"] 149 version = "v1.2.2" 150 hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 151 [mod."github.com/go-redis/cache/v9"] 152 version = "v9.0.0" 153 hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 154 + [mod."github.com/go-test/deep"] 155 + version = "v1.1.1" 156 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 157 [mod."github.com/goccy/go-json"] 158 version = "v0.10.5" 159 hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" ··· 161 version = "v1.3.2" 162 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 163 [mod."github.com/golang-jwt/jwt/v5"] 164 + version = "v5.2.3" 165 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 166 [mod."github.com/golang/groupcache"] 167 version = "v0.0.0-20241129210726-2c02b8208cf8" 168 hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 169 + [mod."github.com/golang/mock"] 170 + version = "v1.6.0" 171 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 172 [mod."github.com/google/uuid"] 173 version = "v1.6.0" 174 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" ··· 182 version = "v1.4.0" 183 hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 184 [mod."github.com/gorilla/websocket"] 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=" 190 [mod."github.com/hashicorp/go-cleanhttp"] 191 version = "v0.5.2" 192 hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 193 + [mod."github.com/hashicorp/go-multierror"] 194 + version = "v1.1.1" 195 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 196 [mod."github.com/hashicorp/go-retryablehttp"] 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=" 208 [mod."github.com/hashicorp/golang-lru"] 209 version = "v1.0.2" 210 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 211 [mod."github.com/hashicorp/golang-lru/v2"] 212 version = "v2.0.7" 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=" 220 [mod."github.com/hiddeco/sshsig"] 221 version = "v0.2.0" 222 hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" ··· 227 version = "v0.0.4" 228 hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 229 [mod."github.com/ipfs/boxo"] 230 + version = "v0.33.0" 231 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 232 [mod."github.com/ipfs/go-block-format"] 233 + version = "v0.2.2" 234 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 235 [mod."github.com/ipfs/go-cid"] 236 version = "v0.5.0" 237 hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" ··· 245 version = "v1.1.1" 246 hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 247 [mod."github.com/ipfs/go-ipld-cbor"] 248 + version = "v0.2.1" 249 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 250 [mod."github.com/ipfs/go-ipld-format"] 251 + version = "v0.6.2" 252 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 253 [mod."github.com/ipfs/go-log"] 254 version = "v1.0.5" 255 hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" ··· 266 version = "v1.18.0" 267 hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 268 [mod."github.com/klauspost/cpuid/v2"] 269 + version = "v2.3.0" 270 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 271 [mod."github.com/lestrrat-go/blackmagic"] 272 + version = "v1.0.4" 273 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 274 [mod."github.com/lestrrat-go/httpcc"] 275 version = "v1.0.1" 276 hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" ··· 298 [mod."github.com/minio/sha256-simd"] 299 version = "v1.0.1" 300 hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 301 + [mod."github.com/mitchellh/mapstructure"] 302 + version = "v1.5.0" 303 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 304 [mod."github.com/moby/docker-image-spec"] 305 version = "v1.3.1" 306 hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" ··· 334 [mod."github.com/munnerz/goautoneg"] 335 version = "v0.0.0-20191010083416-a7dc8b61c822" 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=" 343 [mod."github.com/opencontainers/go-digest"] 344 version = "v1.0.0" 345 hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" ··· 347 version = "v1.1.1" 348 hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 349 [mod."github.com/opentracing/opentracing-go"] 350 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 351 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 352 [mod."github.com/pjbgf/sha1cd"] 353 version = "v0.3.2" 354 hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" ··· 371 version = "v0.6.2" 372 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 373 [mod."github.com/prometheus/common"] 374 + version = "v0.64.0" 375 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 376 [mod."github.com/prometheus/procfs"] 377 version = "v0.16.1" 378 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 379 [mod."github.com/redis/go-redis/v9"] 380 + version = "v9.7.3" 381 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 382 [mod."github.com/resend/resend-go/v2"] 383 version = "v2.15.0" 384 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 385 + [mod."github.com/ryanuber/go-glob"] 386 + version = "v1.0.0" 387 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 388 [mod."github.com/segmentio/asm"] 389 version = "v1.2.0" 390 hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" ··· 429 version = "v1.1.0" 430 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 431 [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 432 + version = "v0.62.0" 433 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 434 [mod."go.opentelemetry.io/otel"] 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=" 440 [mod."go.opentelemetry.io/otel/metric"] 441 + version = "v1.37.0" 442 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 443 [mod."go.opentelemetry.io/otel/trace"] 444 + version = "v1.37.0" 445 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 446 [mod."go.opentelemetry.io/proto/otlp"] 447 version = "v1.6.0" 448 hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" ··· 456 version = "v1.27.0" 457 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 458 [mod."golang.org/x/crypto"] 459 + version = "v0.40.0" 460 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 461 [mod."golang.org/x/exp"] 462 + version = "v0.0.0-20250620022241-b7579e27df2b" 463 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 464 [mod."golang.org/x/net"] 465 + version = "v0.42.0" 466 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 467 [mod."golang.org/x/sync"] 468 + version = "v0.16.0" 469 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 470 [mod."golang.org/x/sys"] 471 + version = "v0.34.0" 472 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 473 + [mod."golang.org/x/text"] 474 + version = "v0.27.0" 475 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 476 [mod."golang.org/x/time"] 477 + version = "v0.12.0" 478 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 479 [mod."golang.org/x/xerrors"] 480 version = "v0.0.0-20240903120638-7835f813f4da" 481 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 482 [mod."google.golang.org/genproto/googleapis/api"] 483 + version = "v0.0.0-20250603155806-513f23925822" 484 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 485 [mod."google.golang.org/genproto/googleapis/rpc"] 486 + version = "v0.0.0-20250603155806-513f23925822" 487 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 488 [mod."google.golang.org/grpc"] 489 + version = "v1.73.0" 490 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 491 [mod."google.golang.org/protobuf"] 492 version = "v1.36.6" 493 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" ··· 510 version = "v1.4.1" 511 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 512 [mod."tangled.sh/icyphox.sh/atproto-oauth"] 513 + version = "v0.0.0-20250724194903-28e660378cb1" 514 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+40 -35
nix/modules/appview.nix
··· 1 - {self}: { 2 config, 3 - pkgs, 4 lib, 5 ... 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"; 24 }; 25 }; 26 - }; 27 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 32 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 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 42 }; 43 }; 44 - }; 45 - }
··· 1 + { 2 config, 3 lib, 4 ... 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 + }; 30 }; 31 }; 32 33 + config = mkIf cfg.enable { 34 + systemd.services.tangled-appview = { 35 + description = "tangled appview service"; 36 + wantedBy = ["multi-user.target"]; 37 38 + serviceConfig = { 39 + ListenStream = "0.0.0.0:${toString cfg.port}"; 40 + ExecStart = "${cfg.package}/bin/appview"; 41 + Restart = "always"; 42 + }; 43 44 + environment = { 45 + TANGLED_DB_PATH = "appview.db"; 46 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 47 + }; 48 }; 49 }; 50 + }
+45 -7
nix/modules/knot.nix
··· 1 - {self}: { 2 config, 3 pkgs, 4 lib, ··· 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled knot"; 16 }; 17 18 appviewEndpoint = mkOption { ··· 53 }; 54 }; 55 56 server = { 57 listenAddr = mkOption { 58 type = types.str; ··· 94 }; 95 96 config = mkIf cfg.enable { 97 - environment.systemPackages = with pkgs; [ 98 - git 99 - self.packages."${pkgs.system}".knot 100 ]; 101 102 - system.activationScripts.gitConfig = '' 103 mkdir -p "${cfg.repo.scanPath}" 104 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 105 ··· 108 [user] 109 name = Git User 110 email = git@example.com 111 EOF 112 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 113 ''; 114 ··· 135 mode = "0555"; 136 text = '' 137 #!${pkgs.stdenv.shell} 138 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 139 -output authorized-keys \ 140 -internal-api "http://${cfg.server.internalListenAddr}" \ 141 -git-dir "${cfg.repo.scanPath}" \ ··· 160 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 161 ]; 162 EnvironmentFile = cfg.server.secretFile; 163 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 164 Restart = "always"; 165 }; 166 };
··· 1 + { 2 config, 3 pkgs, 4 lib, ··· 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled knot"; 16 + }; 17 + 18 + package = mkOption { 19 + type = types.package; 20 + description = "Package to use for the knot"; 21 }; 22 23 appviewEndpoint = mkOption { ··· 58 }; 59 }; 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 + 83 server = { 84 listenAddr = mkOption { 85 type = types.str; ··· 121 }; 122 123 config = mkIf cfg.enable { 124 + environment.systemPackages = [ 125 + pkgs.git 126 + cfg.package 127 ]; 128 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 '' 138 mkdir -p "${cfg.repo.scanPath}" 139 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 140 ··· 143 [user] 144 name = Git User 145 email = git@example.com 146 + [receive] 147 + advertisePushOptions = true 148 EOF 149 + ${setMotd} 150 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 151 ''; 152 ··· 173 mode = "0555"; 174 text = '' 175 #!${pkgs.stdenv.shell} 176 + ${cfg.package}/bin/knot keys \ 177 -output authorized-keys \ 178 -internal-api "http://${cfg.server.internalListenAddr}" \ 179 -git-dir "${cfg.repo.scanPath}" \ ··· 198 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 199 ]; 200 EnvironmentFile = cfg.server.secretFile; 201 + ExecStart = "${cfg.package}/bin/knot server"; 202 Restart = "always"; 203 }; 204 };
+6 -3
nix/modules/spindle.nix
··· 1 - {self}: { 2 config, 3 - pkgs, 4 lib, 5 ... 6 }: let ··· 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled spindle"; 16 }; 17 18 server = { ··· 89 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 91 ]; 92 - ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 93 Restart = "always"; 94 }; 95 };
··· 1 + { 2 config, 3 lib, 4 ... 5 }: let ··· 12 type = types.bool; 13 default = false; 14 description = "Enable a tangled spindle"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the spindle"; 19 }; 20 21 server = { ··· 92 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 ]; 95 + ExecStart = "${cfg.package}/bin/spindle"; 96 Restart = "always"; 97 }; 98 };
+1
nix/vm.nix
··· 48 ]; 49 services.tangled-knot = { 50 enable = true; 51 server = { 52 secretFile = "/var/lib/knot/secret"; 53 hostname = "localhost:6000";
··· 48 ]; 49 services.tangled-knot = { 50 enable = true; 51 + motd = "Welcome to the development knot!\n"; 52 server = { 53 secretFile = "/var/lib/knot/secret"; 54 hostname = "localhost:6000";
+4
rbac/rbac.go
··· 11 ) 12 13 const ( 14 Model = ` 15 [request_definition] 16 r = sub, dom, obj, act
··· 11 ) 12 13 const ( 14 + ThisServer = "thisserver" // resource identifier for local rbac enforcement 15 + ) 16 + 17 + const ( 18 Model = ` 19 [request_definition] 20 r = sub, dom, obj, act
+23 -6
spindle/config/config.go
··· 2 3 import ( 4 "context" 5 6 "github.com/sethvargo/go-envconfig" 7 ) 8 9 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"` 16 } 17 18 type Pipelines struct {
··· 2 3 import ( 4 "context" 5 + "fmt" 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/sethvargo/go-envconfig" 9 ) 10 11 type Server struct { 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"` 33 } 34 35 type Pipelines struct {
+42 -19
spindle/engine/engine.go
··· 11 "sync" 12 "time" 13 14 "github.com/docker/docker/api/types/container" 15 "github.com/docker/docker/api/types/image" 16 "github.com/docker/docker/api/types/mount" ··· 18 "github.com/docker/docker/api/types/volume" 19 "github.com/docker/docker/client" 20 "github.com/docker/docker/pkg/stdcopy" 21 "tangled.sh/tangled.sh/core/log" 22 "tangled.sh/tangled.sh/core/notifier" 23 "tangled.sh/tangled.sh/core/spindle/config" 24 "tangled.sh/tangled.sh/core/spindle/db" 25 "tangled.sh/tangled.sh/core/spindle/models" 26 ) 27 28 const ( ··· 37 db *db.DB 38 n *notifier.Notifier 39 cfg *config.Config 40 41 cleanupMu sync.Mutex 42 cleanup map[string][]cleanupFunc 43 } 44 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 46 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 if err != nil { 48 return nil, err ··· 56 db: db, 57 n: n, 58 cfg: cfg, 59 } 60 61 e.cleanup = make(map[string][]cleanupFunc) ··· 66 func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 69 - wg := sync.WaitGroup{} 70 for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 74 wid := models.WorkflowId{ 75 PipelineId: pipelineId, 76 Name: w.Name, ··· 102 defer reader.Close() 103 io.Copy(os.Stdout, reader) 104 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 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 defer cancel() 114 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 116 if err != nil { 117 if errors.Is(err, ErrTimedOut) { 118 dbErr := e.db.StatusTimeout(wid, e.n) ··· 135 } 136 137 return nil 138 - }() 139 } 140 141 - wg.Wait() 142 } 143 144 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 186 // ONLY marks pipeline as failed if container's exit code is non-zero. 187 // All other errors are bubbled up. 188 // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 190 191 - for stepIdx, step := range steps { 192 select { 193 case <-ctx.Done(): 194 return ctx.Err() 195 default: 196 } 197 198 - envs := ConstructEnvs(step.Environment) 199 envs.AddEnv("HOME", workspaceDir) 200 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 202 hostConfig := hostConfig(wid) 203 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 205 Cmd: []string{"bash", "-c", step.Command}, 206 WorkingDir: workspaceDir, 207 Tty: false,
··· 11 "sync" 12 "time" 13 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/docker/docker/api/types/container" 16 "github.com/docker/docker/api/types/image" 17 "github.com/docker/docker/api/types/mount" ··· 19 "github.com/docker/docker/api/types/volume" 20 "github.com/docker/docker/client" 21 "github.com/docker/docker/pkg/stdcopy" 22 + "golang.org/x/sync/errgroup" 23 "tangled.sh/tangled.sh/core/log" 24 "tangled.sh/tangled.sh/core/notifier" 25 "tangled.sh/tangled.sh/core/spindle/config" 26 "tangled.sh/tangled.sh/core/spindle/db" 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/secrets" 29 ) 30 31 const ( ··· 40 db *db.DB 41 n *notifier.Notifier 42 cfg *config.Config 43 + vault secrets.Manager 44 45 cleanupMu sync.Mutex 46 cleanup map[string][]cleanupFunc 47 } 48 49 + func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 50 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 if err != nil { 52 return nil, err ··· 60 db: db, 61 n: n, 62 cfg: cfg, 63 + vault: vault, 64 } 65 66 e.cleanup = make(map[string][]cleanupFunc) ··· 71 func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 72 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 73 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) 91 for _, w := range pipeline.Workflows { 92 + eg.Go(func() error { 93 wid := models.WorkflowId{ 94 PipelineId: pipelineId, 95 Name: w.Name, ··· 121 defer reader.Close() 122 io.Copy(os.Stdout, reader) 123 124 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 defer cancel() 126 127 + err = e.StartSteps(ctx, wid, w, allSecrets) 128 if err != nil { 129 if errors.Is(err, ErrTimedOut) { 130 dbErr := e.db.StatusTimeout(wid, e.n) ··· 147 } 148 149 return nil 150 + }) 151 } 152 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 + } 158 } 159 160 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 202 // ONLY marks pipeline as failed if container's exit code is non-zero. 203 // All other errors are bubbled up. 204 // Fixed version of the step execution logic 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 + } 210 211 + for stepIdx, step := range w.Steps { 212 select { 213 case <-ctx.Done(): 214 return ctx.Err() 215 default: 216 } 217 218 + envs := append(EnvVars(nil), workflowEnvs...) 219 + for k, v := range step.Environment { 220 + envs.AddEnv(k, v) 221 + } 222 envs.AddEnv("HOME", workspaceDir) 223 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 224 225 hostConfig := hostConfig(wid) 226 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 227 + Image: w.Image, 228 Cmd: []string{"bash", "-c", step.Command}, 229 WorkingDir: workspaceDir, 230 Tty: false,
+129 -2
spindle/ingester.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 8 "tangled.sh/tangled.sh/core/api/tangled" 9 "tangled.sh/tangled.sh/core/eventconsumer" 10 11 "github.com/bluesky-social/jetstream/pkg/models" 12 ) 13 14 type Ingester func(ctx context.Context, e *models.Event) error ··· 33 s.ingestMember(ctx, e) 34 case tangled.RepoNSID: 35 s.ingestRepo(ctx, e) 36 } 37 38 return err ··· 72 return fmt.Errorf("failed to enforce permissions: %w", err) 73 } 74 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 76 l.Error("failed to add member", "error", err) 77 return fmt.Errorf("failed to add member: %w", err) 78 } ··· 90 return nil 91 } 92 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 94 var err error 95 96 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 ··· 127 return fmt.Errorf("failed to add repo: %w", err) 128 } 129 130 // add this knot to the event consumer 131 src := eventconsumer.NewKnotSource(record.Knot) 132 s.ks.AddSource(context.Background(), src) ··· 136 } 137 return nil 138 }
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 9 "tangled.sh/tangled.sh/core/api/tangled" 10 "tangled.sh/tangled.sh/core/eventconsumer" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/rbac" 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" 18 "github.com/bluesky-social/jetstream/pkg/models" 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 ) 21 22 type Ingester func(ctx context.Context, e *models.Event) error ··· 41 s.ingestMember(ctx, e) 42 case tangled.RepoNSID: 43 s.ingestRepo(ctx, e) 44 + case tangled.RepoCollaboratorNSID: 45 + s.ingestCollaborator(ctx, e) 46 } 47 48 return err ··· 82 return fmt.Errorf("failed to enforce permissions: %w", err) 83 } 84 85 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 86 l.Error("failed to add member", "error", err) 87 return fmt.Errorf("failed to add member: %w", err) 88 } ··· 100 return nil 101 } 102 103 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 104 var err error 105 + did := e.Did 106 + resolver := idresolver.DefaultResolver() 107 108 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 109 ··· 139 return fmt.Errorf("failed to add repo: %w", err) 140 } 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 + 162 // add this knot to the event consumer 163 src := eventconsumer.NewKnotSource(record.Knot) 164 s.ks.AddSource(context.Background(), src) ··· 168 } 169 return nil 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 ) 9 10 type Pipeline struct { 11 Workflows []Workflow 12 } 13 ··· 63 swf.Environment = workflowEnvToMap(twf.Environment) 64 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 setup := &setupSteps{} 69 70 setup.addStep(nixConfStep()) ··· 79 80 workflows = append(workflows, *swf) 81 } 82 - return &Pipeline{Workflows: workflows} 83 } 84 85 func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { ··· 115 116 return path.Join(nixery, dependencies) 117 } 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 - }
··· 8 ) 9 10 type Pipeline struct { 11 + RepoOwner string 12 + RepoName string 13 Workflows []Workflow 14 } 15 ··· 65 swf.Environment = workflowEnvToMap(twf.Environment) 66 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 68 setup := &setupSteps{} 69 70 setup.addStep(nixConfStep()) ··· 79 80 workflows = append(workflows, *swf) 81 } 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 + } 89 } 90 91 func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { ··· 121 122 return path.Join(nixery, dependencies) 123 }
+3
spindle/models/setup_steps.go
··· 102 continue 103 } 104 105 // collect packages from custom registries 106 for _, pkg := range packages { 107 customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
··· 102 continue 103 } 104 105 + if len(packages) == 0 { 106 + customPackages = append(customPackages, registry) 107 + } 108 // collect packages from custom registries 109 for _, pkg := range packages { 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 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" ··· 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/eventconsumer" 13 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 14 "tangled.sh/tangled.sh/core/jetstream" 15 "tangled.sh/tangled.sh/core/log" 16 "tangled.sh/tangled.sh/core/notifier" ··· 20 "tangled.sh/tangled.sh/core/spindle/engine" 21 "tangled.sh/tangled.sh/core/spindle/models" 22 "tangled.sh/tangled.sh/core/spindle/queue" 23 ) 24 25 const ( 26 rbacDomain = "thisserver" 27 ) 28 29 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 39 } 40 41 func Run(ctx context.Context) error { ··· 59 60 n := notifier.New() 61 62 - eng, err := engine.New(ctx, cfg, d, &n) 63 if err != nil { 64 return err 65 } ··· 69 collections := []string{ 70 tangled.SpindleMemberNSID, 71 tangled.RepoNSID, 72 } 73 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 if err != nil { ··· 76 } 77 jc.AddDid(cfg.Server.Owner) 78 79 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, 88 } 89 90 err = e.AddSpindle(rbacDomain) ··· 100 // starts a job queue runner in the background 101 jq.Start() 102 defer jq.Stop() 103 104 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 if err != nil { ··· 144 mux := chi.NewRouter() 145 146 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`)) 171 }) 172 mux.HandleFunc("/events", s.Events) 173 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 174 w.Write([]byte(s.cfg.Server.Owner)) 175 }) 176 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 177 return mux 178 } 179 180 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
··· 2 3 import ( 4 "context" 5 + _ "embed" 6 "encoding/json" 7 "fmt" 8 "log/slog" ··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/eventconsumer" 14 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 + "tangled.sh/tangled.sh/core/idresolver" 16 "tangled.sh/tangled.sh/core/jetstream" 17 "tangled.sh/tangled.sh/core/log" 18 "tangled.sh/tangled.sh/core/notifier" ··· 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 "tangled.sh/tangled.sh/core/spindle/models" 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 + "tangled.sh/tangled.sh/core/spindle/xrpc" 27 ) 28 29 + //go:embed motd 30 + var motd []byte 31 + 32 const ( 33 rbacDomain = "thisserver" 34 ) 35 36 type Spindle struct { 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 48 } 49 50 func Run(ctx context.Context) error { ··· 68 69 n := notifier.New() 70 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) 97 if err != nil { 98 return err 99 } ··· 103 collections := []string{ 104 tangled.SpindleMemberNSID, 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 107 } 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 109 if err != nil { ··· 111 } 112 jc.AddDid(cfg.Server.Owner) 113 114 + resolver := idresolver.DefaultResolver() 115 + 116 spindle := Spindle{ 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, 127 } 128 129 err = e.AddSpindle(rbacDomain) ··· 139 // starts a job queue runner in the background 140 jq.Start() 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 + } 147 148 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 149 if err != nil { ··· 188 mux := chi.NewRouter() 189 190 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 191 + w.Write(motd) 192 }) 193 mux.HandleFunc("/events", s.Events) 194 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 195 w.Write([]byte(s.cfg.Server.Owner)) 196 }) 197 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 198 + 199 + mux.Mount("/xrpc", s.XrpcRouter()) 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() 217 } 218 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 + }