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

Compare changes

Choose any two refs to compare.

Changed files
+10033 -2611
.tangled
workflows
api
appview
config
db
dns
idresolver
issues
knots
middleware
notify
oauth
pages
pipelines
posthog
pulls
repo
reporesolver
settings
signup
spindles
state
strings
xrpcclient
avatar
src
cmd
docs
guard
hook
idresolver
jetstream
knotserver
lexicons
nix
rbac
spindle
+1
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc
+2 -1
.tangled/workflows/fmt.yml
··· 14 14 15 15 - name: "go fmt" 16 16 command: | 17 - gofmt -l . 17 + unformatted=$(gofmt -l .) 18 + test -z "$unformatted" || (echo "$unformatted" && exit 1) 18 19
+430
api/tangled/cbor_gen.go
··· 5854 5854 5855 5855 return nil 5856 5856 } 5857 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5858 + if t == nil { 5859 + _, err := w.Write(cbg.CborNull) 5860 + return err 5861 + } 5862 + 5863 + cw := cbg.NewCborWriter(w) 5864 + 5865 + if _, err := cw.Write([]byte{164}); err != nil { 5866 + return err 5867 + } 5868 + 5869 + // t.Repo (string) (string) 5870 + if len("repo") > 1000000 { 5871 + return xerrors.Errorf("Value in field \"repo\" was too long") 5872 + } 5873 + 5874 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5875 + return err 5876 + } 5877 + if _, err := cw.WriteString(string("repo")); err != nil { 5878 + return err 5879 + } 5880 + 5881 + if len(t.Repo) > 1000000 { 5882 + return xerrors.Errorf("Value in field t.Repo was too long") 5883 + } 5884 + 5885 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5886 + return err 5887 + } 5888 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5889 + return err 5890 + } 5891 + 5892 + // t.LexiconTypeID (string) (string) 5893 + if len("$type") > 1000000 { 5894 + return xerrors.Errorf("Value in field \"$type\" was too long") 5895 + } 5896 + 5897 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5898 + return err 5899 + } 5900 + if _, err := cw.WriteString(string("$type")); err != nil { 5901 + return err 5902 + } 5903 + 5904 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5905 + return err 5906 + } 5907 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5908 + return err 5909 + } 5910 + 5911 + // t.Subject (string) (string) 5912 + if len("subject") > 1000000 { 5913 + return xerrors.Errorf("Value in field \"subject\" was too long") 5914 + } 5915 + 5916 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5917 + return err 5918 + } 5919 + if _, err := cw.WriteString(string("subject")); err != nil { 5920 + return err 5921 + } 5922 + 5923 + if len(t.Subject) > 1000000 { 5924 + return xerrors.Errorf("Value in field t.Subject was too long") 5925 + } 5926 + 5927 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5928 + return err 5929 + } 5930 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5931 + return err 5932 + } 5933 + 5934 + // t.CreatedAt (string) (string) 5935 + if len("createdAt") > 1000000 { 5936 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5937 + } 5938 + 5939 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5940 + return err 5941 + } 5942 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5943 + return err 5944 + } 5945 + 5946 + if len(t.CreatedAt) > 1000000 { 5947 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5948 + } 5949 + 5950 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5951 + return err 5952 + } 5953 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5954 + return err 5955 + } 5956 + return nil 5957 + } 5958 + 5959 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5960 + *t = RepoCollaborator{} 5961 + 5962 + cr := cbg.NewCborReader(r) 5963 + 5964 + maj, extra, err := cr.ReadHeader() 5965 + if err != nil { 5966 + return err 5967 + } 5968 + defer func() { 5969 + if err == io.EOF { 5970 + err = io.ErrUnexpectedEOF 5971 + } 5972 + }() 5973 + 5974 + if maj != cbg.MajMap { 5975 + return fmt.Errorf("cbor input should be of type map") 5976 + } 5977 + 5978 + if extra > cbg.MaxLength { 5979 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5980 + } 5981 + 5982 + n := extra 5983 + 5984 + nameBuf := make([]byte, 9) 5985 + for i := uint64(0); i < n; i++ { 5986 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5987 + if err != nil { 5988 + return err 5989 + } 5990 + 5991 + if !ok { 5992 + // Field doesn't exist on this type, so ignore it 5993 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5994 + return err 5995 + } 5996 + continue 5997 + } 5998 + 5999 + switch string(nameBuf[:nameLen]) { 6000 + // t.Repo (string) (string) 6001 + case "repo": 6002 + 6003 + { 6004 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6005 + if err != nil { 6006 + return err 6007 + } 6008 + 6009 + t.Repo = string(sval) 6010 + } 6011 + // t.LexiconTypeID (string) (string) 6012 + case "$type": 6013 + 6014 + { 6015 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6016 + if err != nil { 6017 + return err 6018 + } 6019 + 6020 + t.LexiconTypeID = string(sval) 6021 + } 6022 + // t.Subject (string) (string) 6023 + case "subject": 6024 + 6025 + { 6026 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6027 + if err != nil { 6028 + return err 6029 + } 6030 + 6031 + t.Subject = string(sval) 6032 + } 6033 + // t.CreatedAt (string) (string) 6034 + case "createdAt": 6035 + 6036 + { 6037 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6038 + if err != nil { 6039 + return err 6040 + } 6041 + 6042 + t.CreatedAt = string(sval) 6043 + } 6044 + 6045 + default: 6046 + // Field doesn't exist on this type, so ignore it 6047 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6048 + return err 6049 + } 6050 + } 6051 + } 6052 + 6053 + return nil 6054 + } 5857 6055 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5858 6056 if t == nil { 5859 6057 _, err := w.Write(cbg.CborNull) ··· 8225 8423 8226 8424 return nil 8227 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 4 5 5 // schema: sh.tangled.repo.issue.state.closed 6 6 7 - const () 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 8 10 9 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.open 6 6 7 - const () 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 8 10 9 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.closed 6 6 7 - const () 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 8 10 9 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.merged 6 6 7 - const () 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 8 10 9 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.open 6 6 7 - const () 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 8 10 9 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+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 + }
+21 -5
appview/config/config.go
··· 10 10 ) 11 11 12 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default spindle 21 + AppPassword string `env:"APP_PASSWORD"` 18 22 } 19 23 20 24 type OAuthConfig struct { ··· 59 63 DB int `env:"DB, default=0"` 60 64 } 61 65 66 + type PdsConfig struct { 67 + Host string `env:"HOST, default=https://tngl.sh"` 68 + AdminSecret string `env:"ADMIN_SECRET"` 69 + } 70 + 71 + type Cloudflare struct { 72 + ApiToken string `env:"API_TOKEN"` 73 + ZoneId string `env:"ZONE_ID"` 74 + } 75 + 62 76 func (cfg RedisConfig) ToURL() string { 63 77 u := &url.URL{ 64 78 Scheme: "redis", ··· 84 98 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 99 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 100 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 101 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 102 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 103 } 88 104 89 105 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 + }
+82 -5
appview/db/db.go
··· 355 355 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 356 356 357 357 -- constraints 358 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 359 358 unique (did, instance, subject) 360 359 ); 361 360 ··· 435 434 bytes integer not null check (bytes >= 0), 436 435 437 436 unique(repo_at, ref, language) 437 + ); 438 + 439 + create table if not exists signups_inflight ( 440 + id integer primary key autoincrement, 441 + email text not null unique, 442 + invite_code text not null, 443 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 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) 438 459 ); 439 460 440 461 create table if not exists migrations ( ··· 579 600 return nil 580 601 }) 581 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 + 582 658 return &DB{db}, nil 583 659 } 584 660 ··· 652 728 kind := rv.Kind() 653 729 654 730 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 655 - if kind == reflect.Slice || kind == reflect.Array { 731 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 656 732 if rv.Len() == 0 { 657 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 733 + // always false 734 + return "1 = 0" 658 735 } 659 736 660 737 placeholders := make([]string, rv.Len()) ··· 671 748 func (f filter) Arg() []any { 672 749 rv := reflect.ValueOf(f.arg) 673 750 kind := rv.Kind() 674 - if kind == reflect.Slice || kind == reflect.Array { 751 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 675 752 if rv.Len() == 0 { 676 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 753 + return nil 677 754 } 678 755 679 756 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 103 query := ` 104 104 select email, did 105 105 from emails 106 - where 107 - verified = ? 106 + where 107 + verified = ? 108 108 and email in (` + strings.Join(placeholders, ",") + `) 109 109 ` 110 110 ··· 153 153 ` 154 154 var count int 155 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 161 + 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 156 170 if err != nil { 157 171 return false, err 158 172 }
+2 -2
appview/db/follow.go
··· 12 12 Rkey string 13 13 } 14 14 15 - func AddFollow(e Execer, userDid, subjectDid, rkey string) error { 15 + func AddFollow(e Execer, follow *Follow) error { 16 16 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 - _, err := e.Exec(query, userDid, subjectDid, rkey) 17 + _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 18 18 return err 19 19 } 20 20
+17 -12
appview/db/issues.go
··· 9 9 ) 10 10 11 11 type Issue struct { 12 + ID int64 12 13 RepoAt syntax.ATURI 13 14 OwnerDid string 14 15 IssueId int ··· 65 66 66 67 issue.IssueId = nextId 67 68 68 - _, err = tx.Exec(` 69 + res, err := tx.Exec(` 69 70 insert into issues (repo_at, owner_did, issue_id, title, body) 70 71 values (?, ?, ?, ?, ?) 71 72 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 72 73 if err != nil { 73 74 return err 74 75 } 76 + 77 + lastID, err := res.LastInsertId() 78 + if err != nil { 79 + return err 80 + } 81 + issue.ID = lastID 75 82 76 83 if err := tx.Commit(); err != nil { 77 84 return err ··· 89 96 var issueAt string 90 97 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 98 return issueAt, err 92 - } 93 - 94 - func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) { 95 - var issueId int 96 - err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId) 97 - return issueId - 1, err 98 99 } 99 100 100 101 func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { ··· 114 115 ` 115 116 with numbered_issue as ( 116 117 select 118 + i.id, 117 119 i.owner_did, 118 120 i.issue_id, 119 121 i.created, ··· 132 134 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 135 ) 134 136 select 137 + id, 135 138 owner_did, 136 139 issue_id, 137 140 created, ··· 153 156 var issue Issue 154 157 var createdAt string 155 158 var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 159 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 160 if err != nil { 158 161 return nil, err 159 162 } ··· 182 185 183 186 rows, err := e.Query( 184 187 `select 188 + i.id, 185 189 i.owner_did, 186 190 i.repo_at, 187 191 i.issue_id, ··· 213 217 var issueCreatedAt, repoCreatedAt string 214 218 var repo Repo 215 219 err := rows.Scan( 220 + &issue.ID, 216 221 &issue.OwnerDid, 217 222 &issue.RepoAt, 218 223 &issue.IssueId, ··· 257 262 } 258 263 259 264 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 260 - query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 265 + query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 261 266 row := e.QueryRow(query, repoAt, issueId) 262 267 263 268 var issue Issue 264 269 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 270 + err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 271 if err != nil { 267 272 return nil, err 268 273 } ··· 277 282 } 278 283 279 284 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 280 - query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 285 + query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 281 286 row := e.QueryRow(query, repoAt, issueId) 282 287 283 288 var issue Issue 284 289 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 290 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 286 291 if err != nil { 287 292 return nil, nil, err 288 293 }
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+2 -36
appview/db/repos.go
··· 391 391 var description, spindle sql.NullString 392 392 393 393 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 394 + select did, name, knot, created, at_uri, description, spindle 395 395 from repos 396 396 where did = ? and name = ? 397 397 `, ··· 550 550 return &repo, nil 551 551 } 552 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 553 func UpdateDescription(e Execer, repoAt, newDescription string) error { 562 554 _, err := e.Exec( 563 555 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 564 556 return err 565 557 } 566 558 567 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 559 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 568 560 _, err := e.Exec( 569 561 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 570 562 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 563 } 598 564 599 565 type RepoStats struct {
+29
appview/db/signup.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + } 11 + 12 + func AddInflightSignup(e Execer, signup InflightSignup) error { 13 + query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 + _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 + return err 16 + } 17 + 18 + func DeleteInflightSignup(e Execer, email string) error { 19 + query := `delete from signups_inflight where email = ?` 20 + _, err := e.Exec(query, email) 21 + return err 22 + } 23 + 24 + func GetEmailForCode(e Execer, inviteCode string) (string, error) { 25 + query := `select email from signups_inflight where invite_code = ?` 26 + var email string 27 + err := e.QueryRow(query, inviteCode).Scan(&email) 28 + return email, err 29 + }
+7 -2
appview/db/star.go
··· 33 33 return nil 34 34 } 35 35 36 - func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error { 36 + func AddStar(e Execer, star *Star) error { 37 37 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 38 - _, err := e.Exec(query, starredByDid, repoAt, rkey) 38 + _, err := e.Exec( 39 + query, 40 + star.StarredByDid, 41 + star.RepoAt.String(), 42 + star.Rkey, 43 + ) 39 44 return err 40 45 } 41 46
+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 - }
+81 -4
appview/ingester.go
··· 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 15 "tangled.sh/tangled.sh/core/appview/config" 16 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 17 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/rbac" 20 20 ) 21 21 ··· 64 64 err = i.ingestSpindleMember(e) 65 65 case tangled.SpindleNSID: 66 66 err = i.ingestSpindle(e) 67 + case tangled.StringNSID: 68 + err = i.ingestString(e) 67 69 } 68 70 l = i.Logger.With("nsid", e.Commit.Collection) 69 71 } ··· 100 102 l.Error("invalid record", "err", err) 101 103 return err 102 104 } 103 - err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey) 105 + err = db.AddStar(i.Db, &db.Star{ 106 + StarredByDid: did, 107 + RepoAt: subjectUri, 108 + Rkey: e.Commit.RKey, 109 + }) 104 110 case models.CommitOperationDelete: 105 111 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 106 112 } ··· 129 135 return err 130 136 } 131 137 132 - subjectDid := record.Subject 133 - err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey) 138 + err = db.AddFollow(i.Db, &db.Follow{ 139 + UserDid: did, 140 + SubjectDid: record.Subject, 141 + Rkey: e.Commit.RKey, 142 + }) 134 143 case models.CommitOperationDelete: 135 144 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 136 145 } ··· 378 387 if err != nil { 379 388 return fmt.Errorf("failed to update ACLs: %w", err) 380 389 } 390 + 391 + l.Info("added spindle member") 381 392 case models.CommitOperationDelete: 382 393 rkey := e.Commit.RKey 383 394 ··· 424 435 if err = i.Enforcer.E.SavePolicy(); err != nil { 425 436 return fmt.Errorf("failed to save ACLs: %w", err) 426 437 } 438 + 439 + l.Info("removed spindle member") 427 440 } 428 441 429 442 return nil ··· 503 516 i.Enforcer.E.LoadPolicy() 504 517 }() 505 518 519 + // remove spindle members first 520 + err = db.RemoveSpindleMember( 521 + tx, 522 + db.FilterEq("owner", did), 523 + db.FilterEq("instance", instance), 524 + ) 525 + if err != nil { 526 + return err 527 + } 528 + 506 529 err = db.DeleteSpindle( 507 530 tx, 508 531 db.FilterEq("owner", did), ··· 532 555 533 556 return nil 534 557 } 558 + 559 + func (i *Ingester) ingestString(e *models.Event) error { 560 + did := e.Did 561 + rkey := e.Commit.RKey 562 + 563 + var err error 564 + 565 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 566 + l.Info("ingesting record") 567 + 568 + ddb, ok := i.Db.Execer.(*db.DB) 569 + if !ok { 570 + return fmt.Errorf("failed to index string record, invalid db cast") 571 + } 572 + 573 + switch e.Commit.Operation { 574 + case models.CommitOperationCreate, models.CommitOperationUpdate: 575 + raw := json.RawMessage(e.Commit.Record) 576 + record := tangled.String{} 577 + err = json.Unmarshal(raw, &record) 578 + if err != nil { 579 + l.Error("invalid record", "err", err) 580 + return err 581 + } 582 + 583 + string := db.StringFromRecord(did, rkey, record) 584 + 585 + if err = string.Validate(); err != nil { 586 + l.Error("invalid record", "err", err) 587 + return err 588 + } 589 + 590 + if err = db.AddString(ddb, string); err != nil { 591 + l.Error("failed to add string", "err", err) 592 + return err 593 + } 594 + 595 + return nil 596 + 597 + case models.CommitOperationDelete: 598 + if err := db.DeleteString( 599 + ddb, 600 + db.FilterEq("did", did), 601 + db.FilterEq("rkey", rkey), 602 + ); err != nil { 603 + l.Error("failed to delete", "err", err) 604 + return fmt.Errorf("failed to delete string record: %w", err) 605 + } 606 + 607 + return nil 608 + } 609 + 610 + return nil 611 + }
+18 -33
appview/issues/issues.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 - "github.com/posthog/posthog-go" 18 17 19 18 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview" 21 19 "tangled.sh/tangled.sh/core/appview/config" 22 20 "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 + "tangled.sh/tangled.sh/core/appview/notify" 24 22 "tangled.sh/tangled.sh/core/appview/oauth" 25 23 "tangled.sh/tangled.sh/core/appview/pages" 26 24 "tangled.sh/tangled.sh/core/appview/pagination" 27 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/idresolver" 27 + "tangled.sh/tangled.sh/core/tid" 28 28 ) 29 29 30 30 type Issues struct { ··· 34 34 idResolver *idresolver.Resolver 35 35 db *db.DB 36 36 config *config.Config 37 - posthog posthog.Client 37 + notifier notify.Notifier 38 38 } 39 39 40 40 func New( ··· 44 44 idResolver *idresolver.Resolver, 45 45 db *db.DB, 46 46 config *config.Config, 47 - posthog posthog.Client, 47 + notifier notify.Notifier, 48 48 ) *Issues { 49 49 return &Issues{ 50 50 oauth: oauth, ··· 53 53 idResolver: idResolver, 54 54 db: db, 55 55 config: config, 56 - posthog: posthog, 56 + notifier: notifier, 57 57 } 58 58 } 59 59 ··· 120 120 DidHandleMap: didHandleMap, 121 121 122 122 OrderedReactionKinds: db.OrderedReactionKinds, 123 - Reactions: reactionCountMap, 124 - UserReacted: userReactions, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 125 125 }) 126 126 127 127 } ··· 171 171 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 172 172 Collection: tangled.RepoIssueStateNSID, 173 173 Repo: user.Did, 174 - Rkey: appview.TID(), 174 + Rkey: tid.TID(), 175 175 Record: &lexutil.LexiconTypeDecoder{ 176 176 Val: &tangled.RepoIssueState{ 177 177 Issue: issue.IssueAt, ··· 275 275 } 276 276 277 277 commentId := mathrand.IntN(1000000) 278 - rkey := appview.TID() 278 + rkey := tid.TID() 279 279 280 280 err := db.NewIssueComment(rp.db, &db.Comment{ 281 281 OwnerDid: user.Did, ··· 703 703 return 704 704 } 705 705 706 - err = db.NewIssue(tx, &db.Issue{ 706 + issue := &db.Issue{ 707 707 RepoAt: f.RepoAt, 708 708 Title: title, 709 709 Body: body, 710 710 OwnerDid: user.Did, 711 - }) 712 - if err != nil { 713 - log.Println("failed to create issue", err) 714 - rp.pages.Notice(w, "issues", "Failed to create issue.") 715 - return 716 711 } 717 - 718 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 712 + err = db.NewIssue(tx, issue) 719 713 if err != nil { 720 - log.Println("failed to get issue id", err) 714 + log.Println("failed to create issue", err) 721 715 rp.pages.Notice(w, "issues", "Failed to create issue.") 722 716 return 723 717 } ··· 732 726 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 733 727 Collection: tangled.RepoIssueNSID, 734 728 Repo: user.Did, 735 - Rkey: appview.TID(), 729 + Rkey: tid.TID(), 736 730 Record: &lexutil.LexiconTypeDecoder{ 737 731 Val: &tangled.RepoIssue{ 738 732 Repo: atUri, 739 733 Title: title, 740 734 Body: &body, 741 735 Owner: user.Did, 742 - IssueId: int64(issueId), 736 + IssueId: int64(issue.IssueId), 743 737 }, 744 738 }, 745 739 }) ··· 749 743 return 750 744 } 751 745 752 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 746 + err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 753 747 if err != nil { 754 748 log.Println("failed to set issue at", err) 755 749 rp.pages.Notice(w, "issues", "Failed to create issue.") 756 750 return 757 751 } 758 752 759 - if !rp.config.Core.Dev { 760 - err = rp.posthog.Enqueue(posthog.Capture{ 761 - DistinctId: user.Did, 762 - Event: "new_issue", 763 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 764 - }) 765 - if err != nil { 766 - log.Println("failed to enqueue posthog event:", err) 767 - } 768 - } 753 + rp.notifier.NewIssue(r.Context(), issue) 769 754 770 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 755 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 771 756 return 772 757 } 773 758 }
+3 -4
appview/knots/knots.go
··· 13 13 14 14 "github.com/go-chi/chi/v5" 15 15 "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/appview" 17 16 "tangled.sh/tangled.sh/core/appview/config" 18 17 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 18 "tangled.sh/tangled.sh/core/appview/middleware" 21 19 "tangled.sh/tangled.sh/core/appview/oauth" 22 20 "tangled.sh/tangled.sh/core/appview/pages" 23 21 "tangled.sh/tangled.sh/core/eventconsumer" 22 + "tangled.sh/tangled.sh/core/idresolver" 24 23 "tangled.sh/tangled.sh/core/knotclient" 25 24 "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/tid" 26 26 27 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 28 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 378 378 } 379 379 380 380 w.Write([]byte(strings.Join(memberDids, "\n"))) 381 - return 382 381 } 383 382 384 383 // add member to domain, requires auth and requires invite access ··· 436 435 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 437 436 Collection: tangled.KnotMemberNSID, 438 437 Repo: currentUser.Did, 439 - Rkey: appview.TID(), 438 + Rkey: tid.TID(), 440 439 Record: &lexutil.LexiconTypeDecoder{ 441 440 Val: &tangled.KnotMember{ 442 441 Subject: subjectIdentity.DID.String(),
+4 -12
appview/middleware/middleware.go
··· 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 15 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 16 "tangled.sh/tangled.sh/core/appview/oauth" 18 17 "tangled.sh/tangled.sh/core/appview/pages" 19 18 "tangled.sh/tangled.sh/core/appview/pagination" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/rbac" 22 22 ) 23 23 ··· 167 167 } 168 168 } 169 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 170 func (mw Middleware) ResolveIdent() middlewareFunc { 181 171 excluded := []string{"favicon.ico"} 182 172 ··· 188 178 return 189 179 } 190 180 181 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 182 + 191 183 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 184 if err != nil { 193 185 // invalid did or handle 194 - log.Println("failed to resolve did/handle:", err) 186 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 195 187 mw.pages.Error404(w) 196 188 return 197 189 }
+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) {}
+142 -1
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 9 11 "strings" 12 + "time" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/gorilla/sessions" 13 16 "github.com/lestrrat-go/jwx/v2/jwk" 14 17 "github.com/posthog/posthog-go" 15 18 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 20 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 21 "tangled.sh/tangled.sh/core/appview/config" 18 22 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 23 "tangled.sh/tangled.sh/core/appview/middleware" 21 24 "tangled.sh/tangled.sh/core/appview/oauth" 22 25 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 26 "tangled.sh/tangled.sh/core/appview/pages" 27 + "tangled.sh/tangled.sh/core/idresolver" 24 28 "tangled.sh/tangled.sh/core/knotclient" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 294 299 295 300 log.Println("session saved successfully") 296 301 go o.addToDefaultKnot(oauthRequest.Did) 302 + go o.addToDefaultSpindle(oauthRequest.Did) 297 303 298 304 if !o.config.Core.Dev { 299 305 err = o.posthog.Enqueue(posthog.Capture{ ··· 330 336 return nil, err 331 337 } 332 338 return pubKey, nil 339 + } 340 + 341 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 342 + // use the tangled.sh app password to get an accessJwt 343 + // and create an sh.tangled.spindle.member record with that 344 + 345 + defaultSpindle := "spindle.tangled.sh" 346 + appPassword := o.config.Core.AppPassword 347 + 348 + spindleMembers, err := db.GetSpindleMembers( 349 + o.db, 350 + db.FilterEq("instance", "spindle.tangled.sh"), 351 + db.FilterEq("subject", did), 352 + ) 353 + if err != nil { 354 + log.Printf("failed to get spindle members for did %s: %v", did, err) 355 + return 356 + } 357 + 358 + if len(spindleMembers) != 0 { 359 + log.Printf("did %s is already a member of the default spindle", did) 360 + return 361 + } 362 + 363 + // TODO: hardcoded tangled handle and did for now 364 + tangledHandle := "tangled.sh" 365 + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 366 + 367 + if appPassword == "" { 368 + log.Println("no app password configured, skipping spindle member addition") 369 + return 370 + } 371 + 372 + log.Printf("adding %s to default spindle", did) 373 + 374 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 375 + if err != nil { 376 + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 377 + return 378 + } 379 + 380 + pdsEndpoint := resolved.PDSEndpoint() 381 + if pdsEndpoint == "" { 382 + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 383 + return 384 + } 385 + 386 + sessionPayload := map[string]string{ 387 + "identifier": tangledHandle, 388 + "password": appPassword, 389 + } 390 + sessionBytes, err := json.Marshal(sessionPayload) 391 + if err != nil { 392 + log.Printf("failed to marshal session payload: %v", err) 393 + return 394 + } 395 + 396 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 397 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 398 + if err != nil { 399 + log.Printf("failed to create session request: %v", err) 400 + return 401 + } 402 + sessionReq.Header.Set("Content-Type", "application/json") 403 + 404 + client := &http.Client{Timeout: 30 * time.Second} 405 + sessionResp, err := client.Do(sessionReq) 406 + if err != nil { 407 + log.Printf("failed to create session: %v", err) 408 + return 409 + } 410 + defer sessionResp.Body.Close() 411 + 412 + if sessionResp.StatusCode != http.StatusOK { 413 + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 414 + return 415 + } 416 + 417 + var session struct { 418 + AccessJwt string `json:"accessJwt"` 419 + } 420 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 421 + log.Printf("failed to decode session response: %v", err) 422 + return 423 + } 424 + 425 + record := tangled.SpindleMember{ 426 + LexiconTypeID: "sh.tangled.spindle.member", 427 + Subject: did, 428 + Instance: defaultSpindle, 429 + CreatedAt: time.Now().Format(time.RFC3339), 430 + } 431 + 432 + recordBytes, err := json.Marshal(record) 433 + if err != nil { 434 + log.Printf("failed to marshal spindle member record: %v", err) 435 + return 436 + } 437 + 438 + payload := map[string]interface{}{ 439 + "repo": tangledDid, 440 + "collection": tangled.SpindleMemberNSID, 441 + "rkey": tid.TID(), 442 + "record": json.RawMessage(recordBytes), 443 + } 444 + 445 + payloadBytes, err := json.Marshal(payload) 446 + if err != nil { 447 + log.Printf("failed to marshal request payload: %v", err) 448 + return 449 + } 450 + 451 + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 452 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 453 + if err != nil { 454 + log.Printf("failed to create HTTP request: %v", err) 455 + return 456 + } 457 + 458 + req.Header.Set("Content-Type", "application/json") 459 + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 460 + 461 + resp, err := client.Do(req) 462 + if err != nil { 463 + log.Printf("failed to add user to default spindle: %v", err) 464 + return 465 + } 466 + defer resp.Body.Close() 467 + 468 + if resp.StatusCode != http.StatusOK { 469 + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 470 + return 471 + } 472 + 473 + log.Printf("successfully added %s to default spindle", did) 333 474 } 334 475 335 476 func (o *OAuthHandler) addToDefaultKnot(did string) {
+73
appview/oauth/oauth.go
··· 7 7 "net/url" 8 8 "time" 9 9 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 10 11 "github.com/gorilla/sessions" 11 12 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 13 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 204 205 }) 205 206 206 207 return xrpcClient, nil 208 + } 209 + 210 + // use this to create a client to communicate with knots or spindles 211 + // 212 + // this is a higher level abstraction on ServerGetServiceAuth 213 + type ServiceClientOpts struct { 214 + service string 215 + exp int64 216 + lxm string 217 + dev bool 218 + } 219 + 220 + type ServiceClientOpt func(*ServiceClientOpts) 221 + 222 + func WithService(service string) ServiceClientOpt { 223 + return func(s *ServiceClientOpts) { 224 + s.service = service 225 + } 226 + } 227 + func WithExp(exp int64) ServiceClientOpt { 228 + return func(s *ServiceClientOpts) { 229 + s.exp = exp 230 + } 231 + } 232 + 233 + func WithLxm(lxm string) ServiceClientOpt { 234 + return func(s *ServiceClientOpts) { 235 + s.lxm = lxm 236 + } 237 + } 238 + 239 + func WithDev(dev bool) ServiceClientOpt { 240 + return func(s *ServiceClientOpts) { 241 + s.dev = dev 242 + } 243 + } 244 + 245 + func (s *ServiceClientOpts) Audience() string { 246 + return fmt.Sprintf("did:web:%s", s.service) 247 + } 248 + 249 + func (s *ServiceClientOpts) Host() string { 250 + scheme := "https://" 251 + if s.dev { 252 + scheme = "http://" 253 + } 254 + 255 + return scheme + s.service 256 + } 257 + 258 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 259 + opts := ServiceClientOpts{} 260 + for _, o := range os { 261 + o(&opts) 262 + } 263 + 264 + authorizedClient, err := o.AuthorizedClient(r) 265 + if err != nil { 266 + return nil, err 267 + } 268 + 269 + resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 270 + if err != nil { 271 + return nil, err 272 + } 273 + 274 + return &indigo_xrpc.Client{ 275 + Auth: &indigo_xrpc.AuthInfo{ 276 + AccessJwt: resp.Token, 277 + }, 278 + Host: opts.Host(), 279 + }, nil 207 280 } 208 281 209 282 type ClientMetadata struct {
+4 -1
appview/pages/funcmap.go
··· 191 191 if v.Len() == 0 { 192 192 return nil 193 193 } 194 - return v.Slice(0, min(n, v.Len()-1)).Interface() 194 + return v.Slice(0, min(n, v.Len())).Interface() 195 195 }, 196 196 197 197 "markdown": func(text string) template.HTML { ··· 236 236 }, 237 237 "cssContentHash": CssContentHash, 238 238 "fileTree": filetree.FileTree, 239 + "pathEscape": func(s string) string { 240 + return url.PathEscape(s) 241 + }, 239 242 "pathUnescape": func(s string) string { 240 243 u, _ := url.PathUnescape(s) 241 244 return u
+2 -2
appview/pages/markup/camo.go
··· 9 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+156 -10
appview/pages/pages.go
··· 16 16 "strings" 17 17 "sync" 18 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 19 20 "tangled.sh/tangled.sh/core/appview/commitverify" 20 21 "tangled.sh/tangled.sh/core/appview/config" 21 22 "tangled.sh/tangled.sh/core/appview/db" ··· 30 31 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 31 32 "github.com/alecthomas/chroma/v2/lexers" 32 33 "github.com/alecthomas/chroma/v2/styles" 34 + "github.com/bluesky-social/indigo/atproto/identity" 33 35 "github.com/bluesky-social/indigo/atproto/syntax" 34 36 "github.com/go-git/go-git/v5/plumbing" 35 37 "github.com/go-git/go-git/v5/plumbing/object" 36 - "github.com/microcosm-cc/bluemonday" 37 38 ) 38 39 39 40 //go:embed templates/* static ··· 262 263 return p.executePlain("user/login", w, params) 263 264 } 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 + 265 290 type TimelineParams struct { 266 291 LoggedInUser *oauth.User 267 292 Timeline []db.TimelineEvent ··· 391 416 UserDid string 392 417 UserHandle string 393 418 FollowStatus db.FollowStatus 394 - AvatarUri string 395 419 Followers int 396 420 Following int 397 421 ··· 448 472 return p.executePlain("user/fragments/editPins", w, params) 449 473 } 450 474 451 - type RepoActionsFragmentParams struct { 475 + type RepoStarFragmentParams struct { 452 476 IsStarred bool 453 477 RepoAt syntax.ATURI 454 478 Stats db.RepoStats 455 479 } 456 480 457 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 458 - return p.executePlain("repo/fragments/repoActions", w, params) 481 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 482 + return p.executePlain("repo/fragments/repoStar", w, params) 459 483 } 460 484 461 485 type RepoDescriptionParams struct { ··· 502 526 ext := filepath.Ext(params.ReadmeFileName) 503 527 switch ext { 504 528 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 529 + htmlString = p.rctx.Sanitize(htmlString) 505 530 htmlString = p.rctx.RenderMarkdown(params.Readme) 506 531 params.Raw = false 507 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 532 + params.HTMLReadme = template.HTML(htmlString) 508 533 default: 509 - htmlString = string(params.Readme) 510 534 params.Raw = true 511 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 512 535 } 513 536 } 514 537 ··· 555 578 RepoInfo repoinfo.RepoInfo 556 579 Active string 557 580 BreadCrumbs [][]string 558 - BaseTreeLink string 559 - BaseBlobLink string 581 + TreePath string 560 582 types.RepoTreeResponse 561 583 } 562 584 ··· 626 648 LoggedInUser *oauth.User 627 649 RepoInfo repoinfo.RepoInfo 628 650 Active string 651 + Unsupported bool 652 + IsImage bool 653 + IsVideo bool 654 + ContentSrc string 629 655 BreadCrumbs [][]string 630 656 ShowRendered bool 631 657 RenderToggle bool ··· 693 719 Branches []types.Branch 694 720 Spindles []string 695 721 CurrentSpindle string 722 + Secrets []*tangled.RepoListSecrets_Secret 723 + 696 724 // TODO: use repoinfo.roles 697 725 IsCollaboratorInviteAllowed bool 698 726 } ··· 702 730 return p.executeRepo("repo/settings", w, params) 703 731 } 704 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 + 705 777 type RepoIssuesParams struct { 706 778 LoggedInUser *oauth.User 707 779 RepoInfo repoinfo.RepoInfo ··· 813 885 DidHandleMap map[string]string 814 886 FilteringBy db.PullState 815 887 Stacks map[string]db.Stack 888 + Pipelines map[string]db.Pipeline 816 889 } 817 890 818 891 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1069 1142 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1070 1143 params.Active = "pipelines" 1071 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) 1072 1218 } 1073 1219 1074 1220 func (p *Pages) Static() http.Handler {
+1 -1
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addKnotMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+19 -30
appview/pages/templates/layouts/base.html
··· 14 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 15 {{ block "extrameta" . }}{{ end }} 16 16 </head> 17 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 - <div class="px-1" style="z-index: 20"> 19 - {{ block "topbarLayout" . }} 20 - <div class="grid grid-cols-1 md:grid-cols-12"> 21 - <header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 22 - {{ template "layouts/topbar" . }} 23 - </header> 24 - </div> 25 - {{ end }} 26 - </div> 17 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 + {{ block "topbarLayout" . }} 19 + <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 + {{ template "layouts/topbar" . }} 21 + </header> 22 + {{ end }} 27 23 28 - <div class="px-1 flex flex-col min-h-screen gap-4"> 29 - {{ block "contentLayout" . }} 30 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 24 + {{ block "mainLayout" . }} 25 + <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 + {{ block "contentLayout" . }} 31 27 <div class="col-span-1 md:col-span-2"> 32 28 {{ block "contentLeft" . }} {{ end }} 33 29 </div> ··· 37 33 <div class="col-span-1 md:col-span-2"> 38 34 {{ block "contentRight" . }} {{ end }} 39 35 </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ block "contentAfterLayout" . }} 44 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 36 + {{ end }} 37 + 38 + {{ block "contentAfterLayout" . }} 45 39 <div class="col-span-1 md:col-span-2"> 46 40 {{ block "contentAfterLeft" . }} {{ end }} 47 41 </div> ··· 51 45 <div class="col-span-1 md:col-span-2"> 52 46 {{ block "contentAfterRight" . }} {{ end }} 53 47 </div> 54 - </div> 55 - {{ end }} 56 - </div> 57 - 58 - <div class="px-1 mt-16"> 59 - {{ block "footerLayout" . }} 60 - <div class="grid grid-cols-1 md:grid-cols-12"> 61 - <footer class="col-span-1 md:col-start-3 md:col-span-8"> 62 - {{ template "layouts/footer" . }} 63 - </footer> 48 + {{ end }} 64 49 </div> 65 - {{ end }} 66 - </div> 50 + {{ end }} 67 51 52 + {{ block "footerLayout" . }} 53 + <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 + {{ template "layouts/footer" . }} 55 + </footer> 56 + {{ end }} 68 57 </body> 69 58 </html> 70 59 {{ end }}
+44 -3
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $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> 5 45 </div> 46 + </div> 6 47 </div> 7 48 {{ end }}
+23 -1
appview/pages/templates/layouts/repobase.html
··· 19 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 20 </div> 21 21 22 - {{ template "repo/fragments/repoActions" .RepoInfo }} 22 + <div class="flex items-center gap-2 z-auto"> 23 + {{ template "repo/fragments/repoStar" .RepoInfo }} 24 + {{ if .RepoInfo.DisableFork }} 25 + <button 26 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 + disabled 28 + title="Empty repositories cannot be forked" 29 + > 30 + {{ i "git-fork" "w-4 h-4" }} 31 + fork 32 + </button> 33 + {{ else }} 34 + <a 35 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 + hx-boost="true" 37 + href="/{{ .RepoInfo.FullName }}/fork" 38 + > 39 + {{ i "git-fork" "w-4 h-4" }} 40 + fork 41 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 + </a> 43 + {{ end }} 44 + </div> 23 45 </div> 24 46 {{ template "repo/fragments/repoDescription" . }} 25 47 </section>
+39 -18
appview/pages/templates/layouts/topbar.html
··· 1 1 {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 10 + <div id="right-items" class="flex items-center gap-2"> 23 11 {{ 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> 12 + {{ block "newButton" . }} {{ end }} 27 13 {{ block "dropDown" . }} {{ end }} 28 14 {{ else }} 29 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> 30 20 {{ end }} 31 21 </div> 32 22 </div> 33 23 </nav> 34 24 {{ end }} 35 25 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left nav-dropdown"> 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 + 36 44 {{ define "dropDown" }} 37 - <details class="relative inline-block text-left"> 45 + <details class="relative inline-block text-left nav-dropdown"> 38 46 <summary 39 47 class="cursor-pointer list-none flex items-center" 40 48 > ··· 45 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" 46 54 > 47 55 <a href="/{{ $user }}">profile</a> 56 + <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 48 58 <a href="/knots">knots</a> 49 59 <a href="/spindles">spindles</a> 50 60 <a href="/settings">settings</a> ··· 56 66 </a> 57 67 </div> 58 68 </details> 69 + 70 + <script> 71 + document.addEventListener('click', function(event) { 72 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 73 + dropdowns.forEach(function(dropdown) { 74 + if (!dropdown.contains(event.target)) { 75 + dropdown.removeAttribute('open'); 76 + } 77 + }); 78 + }); 79 + </script> 59 80 {{ end }}
+133
appview/pages/templates/legal/privacy.html
··· 1 + {{ define "title" }} privacy policy {{ end }} 2 + {{ define "content" }} 3 + <div class="max-w-4xl mx-auto px-4 py-8"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 + <h1>Privacy Policy</h1> 7 + 8 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 + 10 + <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 + 12 + <h2>1. Information We Collect</h2> 13 + 14 + <h3>Account Information</h3> 15 + <p>When you create an account, we collect:</p> 16 + <ul> 17 + <li>Your chosen username</li> 18 + <li>Email address</li> 19 + <li>Profile information you choose to provide</li> 20 + <li>Authentication data</li> 21 + </ul> 22 + 23 + <h3>Content and Activity</h3> 24 + <p>We store:</p> 25 + <ul> 26 + <li>Code repositories and associated metadata</li> 27 + <li>Issues, pull requests, and comments</li> 28 + <li>Activity logs and usage patterns</li> 29 + <li>Public keys for authentication</li> 30 + </ul> 31 + 32 + <h2>2. Data Location and Hosting</h2> 33 + <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 + <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 + <p class="text-blue-700 dark:text-blue-300"> 36 + <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 + </p> 38 + <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 + <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 + <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 + <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 + </ul> 43 + </div> 44 + 45 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 + <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 + <p class="text-yellow-700 dark:text-yellow-300"> 48 + <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 + </p> 50 + </div> 51 + 52 + <h2>3. Third-Party Data Processors</h2> 53 + <p>We only share your data with the following third-party processors:</p> 54 + 55 + <h3>Resend (Email Services)</h3> 56 + <ul> 57 + <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 + <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 + <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 + </ul> 61 + 62 + <h3>Cloudflare (Image Caching)</h3> 63 + <ul> 64 + <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 + <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 + <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 + </ul> 68 + 69 + <h2>4. How We Use Your Information</h2> 70 + <p>We use your information to:</p> 71 + <ul> 72 + <li>Provide and maintain the Service</li> 73 + <li>Process your transactions and requests</li> 74 + <li>Send you technical notices and support messages</li> 75 + <li>Improve and develop new features</li> 76 + <li>Ensure security and prevent fraud</li> 77 + <li>Comply with legal obligations</li> 78 + </ul> 79 + 80 + <h2>5. Data Sharing and Disclosure</h2> 81 + <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 + <ul> 83 + <li>With the third-party processors listed above</li> 84 + <li>When required by law or legal process</li> 85 + <li>To protect our rights, property, or safety, or that of our users</li> 86 + <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 + </ul> 88 + 89 + <h2>6. Data Security</h2> 90 + <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 + 92 + <h2>7. Data Retention</h2> 93 + <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 + 95 + <h2>8. Your Rights</h2> 96 + <p>Under applicable data protection laws, you have the right to:</p> 97 + <ul> 98 + <li>Access your personal information</li> 99 + <li>Correct inaccurate information</li> 100 + <li>Request deletion of your information</li> 101 + <li>Object to processing of your information</li> 102 + <li>Data portability</li> 103 + <li>Withdraw consent (where applicable)</li> 104 + </ul> 105 + 106 + <h2>9. Cookies and Tracking</h2> 107 + <p>We use cookies and similar technologies to:</p> 108 + <ul> 109 + <li>Maintain your login session</li> 110 + <li>Remember your preferences</li> 111 + <li>Analyze usage patterns to improve the Service</li> 112 + </ul> 113 + <p>You can control cookie settings through your browser preferences.</p> 114 + 115 + <h2>10. Children's Privacy</h2> 116 + <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 + 118 + <h2>11. International Data Transfers</h2> 119 + <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 + 121 + <h2>12. Changes to This Privacy Policy</h2> 122 + <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 + 124 + <h2>13. Contact Information</h2> 125 + <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 + 127 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 + <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 130 + </div> 131 + </div> 132 + </div> 133 + {{ end }}
+71
appview/pages/templates/legal/terms.html
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + <h1>Terms of Service</h1> 8 + 9 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 + 11 + <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 + 13 + <h2>1. Acceptance of Terms</h2> 14 + <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 + 16 + <h2>2. Account Registration</h2> 17 + <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 + 19 + <h2>3. Account Termination</h2> 20 + <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 + <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 + <p class="text-red-700 dark:text-red-300"> 23 + <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 + </p> 25 + <p class="text-red-700 dark:text-red-300 mt-2"> 26 + Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 + </p> 28 + </div> 29 + 30 + <h2>4. Acceptable Use</h2> 31 + <p>You agree not to use the Service to:</p> 32 + <ul> 33 + <li>Violate any applicable laws or regulations</li> 34 + <li>Infringe upon the rights of others</li> 35 + <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 + <li>Engage in spam, phishing, or other deceptive practices</li> 37 + <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 + <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 + </ul> 40 + 41 + <h2>5. Content and Intellectual Property</h2> 42 + <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 + 44 + <h2>6. Privacy</h2> 45 + <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 + 47 + <h2>7. Disclaimers</h2> 48 + <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 + 50 + <h2>8. Limitation of Liability</h2> 51 + <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 + 53 + <h2>9. Indemnification</h2> 54 + <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 + 56 + <h2>10. Governing Law</h2> 57 + <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 + 59 + <h2>11. Changes to Terms</h2> 60 + <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 + 62 + <h2>12. Contact Information</h2> 63 + <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 + 65 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 + <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 68 + </div> 69 + </div> 70 + </div> 71 + {{ end }}
+19 -6
appview/pages/templates/repo/blob.html
··· 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 10 + 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} ··· 44 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 45 {{ if .RenderToggle }} 46 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 49 hx-boost="true" 50 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 51 {{ end }} 52 52 </div> 53 53 </div> 54 54 </div> 55 - {{ if .IsBinary }} 55 + {{ if and .IsBinary .Unsupported }} 56 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 57 + Previews are not supported for this file type. 58 58 </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 59 72 {{ else }} 60 73 <div class="overflow-auto relative"> 61 74 {{ if .ShowRendered }}
+20 -14
appview/pages/templates/repo/commit.html
··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header style="z-index: 20;"> 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 - {{ define "contentLayout" }} 89 - {{ block "content" . }}{{ end }} 90 - {{ end }} 88 + {{ define "mainLayout" }} 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 + {{ block "contentLayout" . }} 91 + {{ block "content" . }}{{ end }} 92 + {{ end }} 91 93 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 }} 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> 96 102 </div> 97 - <main class="col-span-1 md:col-span-10"> 98 - {{ block "contentAfter" . }}{{ end }} 99 - </main> 103 + {{ end }} 100 104 </div> 101 105 {{ end }} 102 106 103 - {{ define "footerLayout" }} 104 - {{ template "layouts/footer" . }} 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/footer" . }} 110 + </footer> 105 111 {{ end }} 106 112 107 113 {{ define "contentAfter" }} ··· 112 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 113 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 114 120 </div> 115 - <div class="sticky top-0 mt-4"> 121 + <div class="sticky top-0 flex-grow max-h-screen"> 116 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 117 123 </div> 118 124 {{end}}
+22 -14
appview/pages/templates/repo/compare/compare.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "topbarLayout" }} 14 - {{ template "layouts/topbar" . }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 15 17 {{ end }} 16 18 17 - {{ define "contentLayout" }} 18 - {{ block "content" . }}{{ end }} 19 - {{ end }} 19 + {{ define "mainLayout" }} 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 + {{ block "contentLayout" . }} 22 + {{ block "content" . }}{{ end }} 23 + {{ end }} 20 24 21 - {{ define "contentAfterLayout" }} 22 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 23 - <div class="col-span-1 md:col-span-2"> 24 - {{ block "contentAfterLeft" . }} {{ end }} 25 + {{ block "contentAfterLayout" . }} 26 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 + {{ block "contentAfterLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-10"> 31 + {{ block "contentAfter" . }}{{ end }} 32 + </main> 25 33 </div> 26 - <main class="col-span-1 md:col-span-10"> 27 - {{ block "contentAfter" . }}{{ end }} 28 - </main> 34 + {{ end }} 29 35 </div> 30 36 {{ end }} 31 37 32 - {{ define "footerLayout" }} 33 - {{ template "layouts/footer" . }} 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/footer" . }} 41 + </footer> 34 42 {{ end }} 35 43 36 44 {{ define "contentAfter" }} ··· 41 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 42 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 43 51 </div> 44 - <div class="sticky top-0 mt-4"> 52 + <div class="sticky top-0 flex-grow max-h-screen"> 45 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 46 54 </div> 47 55 {{end}}
+17 -3
appview/pages/templates/repo/empty.html
··· 23 23 {{ end }} 24 24 </div> 25 25 </div> 26 + {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 + {{ $knot := .RepoInfo.Knot }} 28 + {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.sh" }} 30 + {{ end }} 31 + <div class="w-full flex place-content-center"> 32 + <div class="py-6 w-fit flex flex-col gap-4"> 33 + <p>This is an empty repository. To get started:</p> 34 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 40 + </div> 41 + </div> 26 42 {{ 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> 43 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 30 44 {{ end }} 31 45 </main> 32 46 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 1 {{ define "repo/fragments/diffChangedFiles" }} 2 2 {{ $stat := .Stat }} 3 3 {{ $fileTree := fileTree .ChangedFiles }} 4 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 5 <div class="diff-stat"> 6 6 <div class="flex gap-2 items-center"> 7 7 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 1 {{ define "repo/fragments/interdiffFiles" }} 2 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm md:min-h-screen text-sm"> 3 + <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 4 <div class="diff-stat"> 5 5 <div class="flex gap-2 items-center"> 6 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
-48
appview/pages/templates/repo/fragments/repoActions.html
··· 1 - {{ define "repo/fragments/repoActions" }} 2 - <div class="flex items-center gap-2 z-auto"> 3 - <button 4 - id="starBtn" 5 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 - {{ if .IsStarred }} 7 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 8 - {{ else }} 9 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 10 - {{ end }} 11 - 12 - hx-trigger="click" 13 - hx-target="#starBtn" 14 - hx-swap="outerHTML" 15 - hx-disabled-elt="#starBtn" 16 - > 17 - {{ if .IsStarred }} 18 - {{ i "star" "w-4 h-4 fill-current" }} 19 - {{ else }} 20 - {{ i "star" "w-4 h-4" }} 21 - {{ end }} 22 - <span class="text-sm"> 23 - {{ .Stats.StarCount }} 24 - </span> 25 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 26 - </button> 27 - {{ if .DisableFork }} 28 - <button 29 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 30 - disabled 31 - title="Empty repositories cannot be forked" 32 - > 33 - {{ i "git-fork" "w-4 h-4" }} 34 - fork 35 - </button> 36 - {{ else }} 37 - <a 38 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 39 - hx-boost="true" 40 - href="/{{ .FullName }}/fork" 41 - > 42 - {{ i "git-fork" "w-4 h-4" }} 43 - fork 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </a> 46 - {{ end }} 47 - </div> 48 - {{ end }}
+26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 + {{ define "repo/fragments/repoStar" }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + {{ if .IsStarred }} 6 + hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 + {{ else }} 8 + hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 + {{ end }} 10 + 11 + hx-trigger="click" 12 + hx-target="this" 13 + hx-swap="outerHTML" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .Stats.StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+38 -70
appview/pages/templates/repo/index.html
··· 127 127 {{ end }} 128 128 129 129 {{ define "fileTree" }} 130 - <div 131 - id="file-tree" 132 - class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" 133 - > 134 - {{ $containerstyle := "py-1" }} 135 - {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 130 + <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" > 131 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 136 132 137 - {{ range .Files }} 138 - {{ if not .IsFile }} 139 - <div class="{{ $containerstyle }}"> 140 - <div class="flex justify-between items-center"> 141 - <a 142 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 143 - class="{{ $linkstyle }}" 144 - > 145 - <div class="flex items-center gap-2"> 146 - {{ i "folder" "size-4 fill-current" }} 147 - {{ .Name }} 148 - </div> 149 - </a> 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 }} 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" }} 158 139 159 - {{ range .Files }} 160 - {{ if .IsFile }} 161 - <div class="{{ $containerstyle }}"> 162 - <div class="flex justify-between items-center"> 163 - <a 164 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 165 - class="{{ $linkstyle }}" 166 - > 167 - <div class="flex items-center gap-2"> 168 - {{ i "file" "size-4" }}{{ .Name }} 169 - </div> 170 - </a> 140 + {{ if .IsFile }} 141 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 142 + {{ $icon = "file" }} 143 + {{ $iconStyle = "size-4" }} 144 + {{ end }} 145 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 + <div class="flex items-center gap-2"> 147 + {{ i $icon $iconStyle }}{{ .Name }} 148 + </div> 149 + </a> 150 + </div> 171 151 172 - {{ if .LastCommit }} 173 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 174 - {{ end }} 175 - </div> 176 - </div> 177 - {{ end }} 178 - {{ end }} 179 - </div> 152 + <div class="text-xs col-span-1 text-right"> 153 + {{ with .LastCommit }} 154 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 + {{ end }} 156 + </div> 157 + </div> 158 + {{ end }} 159 + </div> 180 160 {{ end }} 181 161 182 162 {{ define "rightInfo" }} ··· 190 170 {{ define "commitLog" }} 191 171 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 192 172 <div class="flex justify-between items-center"> 193 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 194 - <div class="flex gap-2 items-center font-bold"> 195 - {{ i "logs" "w-4 h-4" }} commits 196 - </div> 197 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 198 - view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 199 - </span> 173 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 174 + {{ i "logs" "w-4 h-4" }} commits 175 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 200 176 </a> 201 177 </div> 202 178 <div class="flex flex-col gap-6"> ··· 298 274 {{ define "branchList" }} 299 275 {{ if gt (len .BranchesTrunc) 0 }} 300 276 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 301 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 302 - <div class="flex gap-2 items-center font-bold"> 303 - {{ i "git-branch" "w-4 h-4" }} branches 304 - </div> 305 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 306 - view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 307 - </span> 277 + <a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 278 + {{ i "git-branch" "w-4 h-4" }} branches 279 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 308 280 </a> 309 281 <div class="flex flex-col gap-1"> 310 282 {{ range .BranchesTrunc }} ··· 341 313 {{ if gt (len .TagsTrunc) 0 }} 342 314 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 343 315 <div class="flex justify-between items-center"> 344 - <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 345 - <div class="flex gap-2 items-center font-bold"> 346 - {{ i "tags" "w-4 h-4" }} tags 347 - </div> 348 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 349 - view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 350 - </span> 316 + <a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 317 + {{ i "tags" "w-4 h-4" }} tags 318 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 351 319 </a> 352 320 </div> 353 321 <div class="flex flex-col gap-1"> ··· 378 346 {{ end }} 379 347 380 348 {{ define "repoAfter" }} 381 - {{- if .HTMLReadme -}} 349 + {{- if or .HTMLReadme .Readme -}} 382 350 <section 383 351 class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 384 352 prose dark:prose-invert dark:[&_pre]:bg-gray-900 ··· 387 355 {{ end }}" 388 356 > 389 357 <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 390 - {{- .HTMLReadme -}} 358 + {{- .Readme -}} 391 359 </pre> 392 360 {{- else -}} 393 361 {{ .HTMLReadme }}
+2 -4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 7 ··· 9 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 10 {{ if $isIssueAuthor }} 11 11 <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 12 author 14 - </span> 15 13 {{ end }} 16 14 17 15 <span class="before:content-['ยท']"></span> 18 16 <a 19 17 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 21 19 id="{{ .CommentId }}"> 22 20 {{ template "repo/fragments/time" .Created }} 23 21 </a>
+7 -8
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 5 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 6 {{ template "user/fragments/picHandleLink" $owner }} 7 7 8 + <!-- show user "hats" --> 9 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 + {{ if $isIssueAuthor }} 11 + <span class="before:content-['ยท']"></span> 12 + author 13 + {{ end }} 14 + 8 15 <span class="before:content-['ยท']"></span> 9 16 <a 10 17 href="#{{ .CommentId }}" ··· 18 25 {{ template "repo/fragments/time" .Created }} 19 26 {{ end }} 20 27 </a> 21 - 22 - <!-- show user "hats" --> 23 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 - {{ if $isIssueAuthor }} 25 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 26 - author 27 - </span> 28 - {{ end }} 29 28 30 29 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 30 {{ if and $isCommentOwner (not .Deleted) }}
+72 -75
appview/pages/templates/repo/log.html
··· 14 14 </h2> 15 15 16 16 <!-- desktop view (hidden on small screens) --> 17 - <table class="w-full border-collapse hidden md:table"> 18 - <thead> 19 - <tr> 20 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th> 21 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th> 22 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th> 23 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th> 24 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th> 25 - </tr> 26 - </thead> 27 - <tbody> 28 - {{ range $index, $commit := .Commits }} 29 - {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 30 - <tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 31 - <td class=" py-3 align-top"> 32 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 33 - {{ if $didOrHandle }} 34 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 35 - {{ else }} 36 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 37 - {{ end }} 38 - </td> 39 - <td class="py-3 align-top font-mono flex items-center"> 40 - {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 41 - {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 42 - {{ if $verified }} 43 - {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 44 - {{ end }} 45 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 46 - {{ slice $commit.Hash.String 0 8 }} 47 - {{ if $verified }} 48 - {{ i "shield-check" "w-4 h-4" }} 49 - {{ end }} 50 - </a> 51 - <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 52 - <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 53 - title="Copy SHA" 54 - onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 55 - {{ i "copy" "w-4 h-4" }} 56 - </button> 57 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 58 - {{ i "folder-code" "w-4 h-4" }} 59 - </a> 60 - </div> 17 + <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 + {{ $grid := "grid grid-cols-14 gap-4" }} 19 + <div class="{{ $grid }}"> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 21 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 + </div> 26 + {{ range $index, $commit := .Commits }} 27 + {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 + <div class="{{ $grid }} py-3"> 29 + <div class="align-top truncate col-span-2"> 30 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 + {{ if $didOrHandle }} 32 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 + {{ else }} 34 + <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 + {{ end }} 36 + </div> 37 + <div class="align-top font-mono flex items-start col-span-3"> 38 + {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 39 + {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 40 + {{ if $verified }} 41 + {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 42 + {{ end }} 43 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 44 + {{ slice $commit.Hash.String 0 8 }} 45 + {{ if $verified }} 46 + {{ i "shield-check" "w-4 h-4" }} 47 + {{ end }} 48 + </a> 49 + <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 50 + <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 51 + title="Copy SHA" 52 + onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 53 + {{ i "copy" "w-4 h-4" }} 54 + </button> 55 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 56 + {{ i "folder-code" "w-4 h-4" }} 57 + </a> 58 + </div> 61 59 62 - </td> 63 - <td class=" py-3 align-top"> 64 - <div class="flex items-center justify-start gap-2"> 65 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 66 - {{ if gt (len $messageParts) 1 }} 67 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 68 - {{ end }} 60 + </div> 61 + <div class="align-top col-span-6"> 62 + <div> 63 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 64 + {{ if gt (len $messageParts) 1 }} 65 + <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 + {{ end }} 69 67 70 - {{ if index $.TagMap $commit.Hash.String }} 71 - {{ range $tag := index $.TagMap $commit.Hash.String }} 72 - <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 73 - {{ $tag }} 74 - </span> 75 - {{ end }} 76 - {{ end }} 77 - </div> 68 + {{ if index $.TagMap $commit.Hash.String }} 69 + {{ range $tag := index $.TagMap $commit.Hash.String }} 70 + <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 71 + {{ $tag }} 72 + </span> 73 + {{ end }} 74 + {{ end }} 75 + </div> 78 76 79 - {{ if gt (len $messageParts) 1 }} 80 - <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 81 - {{ end }} 82 - </td> 83 - <td class="py-3 align-top"> 84 - <!-- ci status --> 85 - {{ $pipeline := index $.Pipelines .Hash.String }} 86 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 87 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 88 - {{ end }} 89 - </td> 90 - <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $commit.Committer.When }}</td> 91 - </tr> 92 - {{ end }} 93 - </tbody> 94 - </table> 77 + {{ if gt (len $messageParts) 1 }} 78 + <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 + {{ end }} 80 + </div> 81 + <div class="align-top col-span-1"> 82 + <!-- ci status --> 83 + {{ $pipeline := index $.Pipelines .Hash.String }} 84 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 + {{ end }} 87 + </div> 88 + <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 + </div> 90 + {{ end }} 91 + </div> 95 92 96 93 <!-- mobile view (visible only on small screens) --> 97 94 <div class="md:hidden">
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 19 20 20 {{ define "sidebar" }} 21 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 22 26 {{ with .Pipeline }} 23 27 {{ $id := .Id }} 24 28 <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"> 25 29 {{ range $name, $all := .Statuses }} 26 30 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 27 31 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 29 33 {{ $lastStatus := $all.Latest }} 30 34 {{ $kind := $lastStatus.Status.String }} 31 35
+5 -7
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 13 13 </span> 14 14 </div> 15 15 16 - <div class="flex-shrink-0 flex items-center"> 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 17 {{ $latestRound := .LastRoundNumber }} 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 19 {{ $commentCount := len $lastSubmission.Comments }} 20 20 {{ if and $pipeline $pipeline.Id }} 21 - <div class="inline-flex items-center gap-2"> 22 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 23 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 24 - </div> 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 25 23 {{ end }} 26 24 <span> 27 - <div class="inline-flex items-center gap-2"> 25 + <div class="inline-flex items-center gap-1"> 28 26 {{ i "message-square" "w-3 h-3 md:hidden" }} 29 27 {{ $commentCount }} 30 28 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 31 29 </div> 32 30 </span> 33 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 34 32 <span> 35 33 <span class="hidden md:inline">round</span> 36 34 <span class="font-mono">#{{ $latestRound }}</span>
+22 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 29 29 {{ end }} 30 30 31 31 {{ define "topbarLayout" }} 32 - {{ template "layouts/topbar" . }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/topbar" . }} 34 + </header> 33 35 {{ end }} 34 36 35 - {{ define "contentLayout" }} 36 - {{ block "content" . }}{{ end }} 37 - {{ end }} 37 + {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 + {{ block "contentLayout" . }} 40 + {{ block "content" . }}{{ end }} 41 + {{ end }} 38 42 39 - {{ define "contentAfterLayout" }} 40 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 41 - <div class="col-span-1 md:col-span-2"> 42 - {{ block "contentAfterLeft" . }} {{ end }} 43 + {{ block "contentAfterLayout" . }} 44 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-10"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 43 51 </div> 44 - <main class="col-span-1 md:col-span-10"> 45 - {{ block "contentAfter" . }}{{ end }} 46 - </main> 52 + {{ end }} 47 53 </div> 48 54 {{ end }} 49 55 50 - {{ define "footerLayout" }} 51 - {{ template "layouts/footer" . }} 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/footer" . }} 59 + </footer> 52 60 {{ end }} 53 61 54 62 ··· 60 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 61 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 62 70 </div> 63 - <div class="sticky top-0 mt-4"> 71 + <div class="sticky top-0 flex-grow max-h-screen"> 64 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 65 73 </div> 66 74 {{end}}
+22 -14
appview/pages/templates/repo/pulls/patch.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "topbarLayout" }} 38 - {{ template "layouts/topbar" . }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/topbar" . }} 40 + </header> 39 41 {{ end }} 40 42 41 - {{ define "contentLayout" }} 42 - {{ block "content" . }}{{ end }} 43 - {{ end }} 43 + {{ define "mainLayout" }} 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + {{ block "content" . }}{{ end }} 47 + {{ end }} 44 48 45 - {{ define "contentAfterLayout" }} 46 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 47 - <div class="col-span-1 md:col-span-2"> 48 - {{ block "contentAfterLeft" . }} {{ end }} 49 + {{ block "contentAfterLayout" . }} 50 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 51 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 52 + {{ block "contentAfterLeft" . }} {{ end }} 53 + </div> 54 + <main class="col-span-1 md:col-span-10"> 55 + {{ block "contentAfter" . }}{{ end }} 56 + </main> 49 57 </div> 50 - <main class="col-span-1 md:col-span-10"> 51 - {{ block "contentAfter" . }}{{ end }} 52 - </main> 58 + {{ end }} 53 59 </div> 54 60 {{ end }} 55 61 56 - {{ define "footerLayout" }} 57 - {{ template "layouts/footer" . }} 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/footer" . }} 65 + </footer> 58 66 {{ end }} 59 67 60 68 {{ define "contentAfter" }} ··· 65 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 66 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 67 75 </div> 68 - <div class="sticky top-0 mt-4"> 76 + <div class="sticky top-0 flex-grow max-h-screen"> 69 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 70 78 </div> 71 79 {{end}}
+43 -52
appview/pages/templates/repo/pulls/pulls.html
··· 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 58 {{ $owner := index $.DidHandleMap .OwnerDid }} 59 59 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 60 {{ $icon := "ban" }} ··· 83 83 {{ template "repo/fragments/time" .Created }} 84 84 </span> 85 85 86 + 87 + {{ $latestRound := .LastRoundNumber }} 88 + {{ $lastSubmission := index .Submissions $latestRound }} 89 + 86 90 <span class="before:content-['ยท']"> 87 - targeting 88 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 89 - {{ .TargetBranch }} 90 - </span> 91 + {{ $commentCount := len $lastSubmission.Comments }} 92 + {{ $s := "s" }} 93 + {{ if eq $commentCount 1 }} 94 + {{ $s = "" }} 95 + {{ end }} 96 + 97 + {{ len $lastSubmission.Comments}} comment{{$s}} 91 98 </span> 92 - {{ if not .IsPatchBased }} 93 - from 94 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 95 - {{ if .IsForkBased }} 96 - {{ if .PullSource.Repo }} 97 - <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 98 - {{- else -}} 99 - <span class="italic">[deleted fork]</span> 100 - {{- end -}} 101 - {{- end -}} 102 - {{- .PullSource.Branch -}} 99 + 100 + <span class="before:content-['ยท']"> 101 + round 102 + <span class="font-mono"> 103 + #{{ .LastRoundNumber }} 104 + </span> 103 105 </span> 106 + 107 + {{ $pipeline := index $.Pipelines .LatestSha }} 108 + {{ if and $pipeline $pipeline.Id }} 109 + <span class="before:content-['ยท']"></span> 110 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 104 111 {{ end }} 105 - <span class="before:content-['ยท']"> 106 - {{ $latestRound := .LastRoundNumber }} 107 - {{ $lastSubmission := index .Submissions $latestRound }} 108 - round 109 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 110 - #{{ .LastRoundNumber }} 111 - </span> 112 - {{ $commentCount := len $lastSubmission.Comments }} 113 - {{ $s := "s" }} 114 - {{ if eq $commentCount 1 }} 115 - {{ $s = "" }} 116 - {{ end }} 117 - 118 - {{ if eq $commentCount 0 }} 119 - awaiting comments 120 - {{ else }} 121 - recieved {{ len $lastSubmission.Comments}} comment{{$s}} 122 - {{ end }} 123 - </span> 124 - </p> 112 + </div> 125 113 </div> 126 114 {{ if .StackId }} 127 115 {{ $otherPulls := index $.Stacks .StackId }} 128 - <details class="bg-white dark:bg-gray-800 group"> 129 - <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 130 - {{ $s := "s" }} 131 - {{ if eq (len $otherPulls) 1 }} 132 - {{ $s = "" }} 133 - {{ end }} 134 - <div class="group-open:hidden flex items-center gap-2"> 135 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 136 - </div> 137 - <div class="hidden group-open:flex items-center gap-2"> 138 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 139 - </div> 140 - </summary> 141 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 142 - </details> 116 + {{ if gt (len $otherPulls) 0 }} 117 + <details class="bg-white dark:bg-gray-800 group"> 118 + <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 119 + {{ $s := "s" }} 120 + {{ if eq (len $otherPulls) 1 }} 121 + {{ $s = "" }} 122 + {{ end }} 123 + <div class="group-open:hidden flex items-center gap-2"> 124 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 125 + </div> 126 + <div class="hidden group-open:flex items-center gap-2"> 127 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 128 + </div> 129 + </summary> 130 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 131 + </details> 132 + {{ end }} 143 133 {{ end }} 144 134 </div> 145 135 {{ end }} ··· 151 141 {{ $root := index . 1 }} 152 142 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 153 143 {{ range $pull := $list }} 144 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 154 145 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 155 146 <div class="flex gap-2 items-center px-6"> 156 147 <div class="flex-grow min-w-0 w-full py-2"> 157 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 148 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 158 149 </div> 159 150 </div> 160 151 </a>
+110
appview/pages/templates/repo/settings/access.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "collaboratorSettings" . }} 10 + </div> 11 + </section> 12 + {{ end }} 13 + 14 + {{ define "collaboratorSettings" }} 15 + <div class="grid grid-cols-1 gap-4 items-center"> 16 + <div class="col-span-1"> 17 + <h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2> 18 + <p class="text-gray-500 dark:text-gray-400"> 19 + Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows. 20 + </p> 21 + </div> 22 + {{ template "collaboratorsGrid" . }} 23 + </div> 24 + {{ end }} 25 + 26 + {{ define "collaboratorsGrid" }} 27 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 28 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 29 + {{ template "addCollaboratorButton" . }} 30 + {{ end }} 31 + {{ range .Collaborators }} 32 + <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 + <div class="flex items-center gap-3"> 34 + <img 35 + src="{{ fullAvatar .Handle }}" 36 + alt="{{ .Handle }}" 37 + class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 + 39 + <div class="flex-1 min-w-0"> 40 + <a href="/{{ .Handle }}" class="block truncate"> 41 + {{ didOrHandle .Did .Handle }} 42 + </a> 43 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 + </div> 45 + </div> 46 + </div> 47 + {{ end }} 48 + </div> 49 + {{ end }} 50 + 51 + {{ define "addCollaboratorButton" }} 52 + <button 53 + class="btn block rounded p-4" 54 + popovertarget="add-collaborator-modal" 55 + popovertargetaction="toggle"> 56 + <div class="flex items-center gap-3"> 57 + <div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 58 + {{ i "user-plus" "size-4" }} 59 + </div> 60 + 61 + <div class="text-left flex-1 min-w-0 block truncate"> 62 + Add collaborator 63 + </div> 64 + </div> 65 + </button> 66 + <div 67 + id="add-collaborator-modal" 68 + popover 69 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 70 + {{ template "addCollaboratorModal" . }} 71 + </div> 72 + {{ end }} 73 + 74 + {{ define "addCollaboratorModal" }} 75 + <form 76 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 77 + hx-indicator="#spinner" 78 + hx-swap="none" 79 + class="flex flex-col gap-2" 80 + > 81 + <label for="add-collaborator" class="uppercase p-0"> 82 + ADD COLLABORATOR 83 + </label> 84 + <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 + <input 86 + type="text" 87 + id="add-collaborator" 88 + name="collaborator" 89 + required 90 + placeholder="@foo.bsky.social" 91 + /> 92 + <div class="flex gap-2 pt-2"> 93 + <button 94 + type="button" 95 + popovertarget="add-collaborator-modal" 96 + popovertargetaction="hide" 97 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 98 + > 99 + {{ i "x" "size-4" }} cancel 100 + </button> 101 + <button type="submit" class="btn w-1/2 flex items-center"> 102 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 103 + <span id="spinner" class="group"> 104 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </span> 106 + </button> 107 + </div> 108 + <div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div> 109 + </form> 110 + {{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
··· 1 + {{ define "repo/settings/fragments/secretListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $secret := index . 1 }} 4 + <div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 6 + <span class="font-mono"> 7 + {{ $secret.Key }} 8 + </span> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 10 + <span>added by</span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 12 + <span class="before:content-['ยท'] before:select-none"></span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 14 + </div> 15 + </div> 16 + <button 17 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 18 + title="Delete secret" 19 + hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 20 + hx-swap="none" 21 + hx-vals='{"key": "{{ $secret.Key }}"}' 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 24 + {{ i "trash-2" "w-5 h-5" }} 25 + <span class="hidden md:inline">delete</span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + </div> 29 + {{ end }}
+16
appview/pages/templates/repo/settings/fragments/sidebar.html
··· 1 + {{ define "repo/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+68
appview/pages/templates/repo/settings/general.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "branchSettings" . }} 10 + {{ template "deleteRepo" . }} 11 + </div> 12 + </section> 13 + {{ end }} 14 + 15 + {{ define "branchSettings" }} 16 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 17 + <div class="col-span-1 md:col-span-2"> 18 + <h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2> 19 + <p class="text-gray-500 dark:text-gray-400"> 20 + The default branch is considered the โ€œbaseโ€ branch in your repository, 21 + against which all pull requests and code commits are automatically made, 22 + unless you specify a different branch. 23 + </p> 24 + </div> 25 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 + <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 27 + <option value="" disabled selected > 28 + Choose a default branch 29 + </option> 30 + {{ range .Branches }} 31 + <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 32 + {{ .Name }} 33 + </option> 34 + {{ end }} 35 + </select> 36 + <button class="btn flex gap-2 items-center" type="submit"> 37 + {{ i "check" "size-4" }} 38 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 + </button> 40 + </form> 41 + </div> 42 + {{ end }} 43 + 44 + {{ define "deleteRepo" }} 45 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 46 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 47 + <div class="col-span-1 md:col-span-2"> 48 + <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2> 49 + <p class="text-red-500 dark:text-red-400 "> 50 + Deleting a repository is irreversible and permanent. Be certain before deleting a repository. 51 + </p> 52 + </div> 53 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 54 + <button 55 + class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 + type="button" 57 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 + hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 + {{ i "trash-2" "size-4" }} 60 + delete 61 + <span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline"> 62 + {{ i "loader-circle" "w-4 h-4" }} 63 + </span> 64 + </button> 65 + </div> 66 + </div> 67 + {{ end }} 68 + {{ end }}
+145
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 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 44 + Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 48 + </option> 49 + {{ range $.Spindles }} 50 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 51 + {{ . }} 52 + </option> 53 + {{ end }} 54 + </select> 55 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 56 + {{ i "check" "size-4" }} 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + </form> 60 + {{ end }} 61 + </div> 62 + {{ end }} 63 + 64 + {{ define "secretSettings" }} 65 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 66 + <div class="col-span-1 md:col-span-2"> 67 + <h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2> 68 + <p class="text-gray-500 dark:text-gray-400"> 69 + Secrets are accessible in workflow runs via environment variables. Anyone 70 + with collaborator access to this repository can add and use secrets in 71 + workflow runs. 72 + </p> 73 + </div> 74 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 75 + {{ template "addSecretButton" . }} 76 + </div> 77 + </div> 78 + <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"> 79 + {{ range .Secrets }} 80 + {{ template "repo/settings/fragments/secretListing" (list $ .) }} 81 + {{ else }} 82 + <div class="flex items-center justify-center p-2 text-gray-500"> 83 + no secrets added yet 84 + </div> 85 + {{ end }} 86 + </div> 87 + {{ end }} 88 + 89 + {{ define "addSecretButton" }} 90 + <button 91 + class="btn flex items-center gap-2" 92 + popovertarget="add-secret-modal" 93 + popovertargetaction="toggle"> 94 + {{ i "plus" "size-4" }} 95 + add secret 96 + </button> 97 + <div 98 + id="add-secret-modal" 99 + popover 100 + 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"> 101 + {{ template "addSecretModal" . }} 102 + </div> 103 + {{ end}} 104 + 105 + {{ define "addSecretModal" }} 106 + <form 107 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 108 + hx-indicator="#spinner" 109 + hx-swap="none" 110 + class="flex flex-col gap-2" 111 + > 112 + <p class="uppercase p-0">ADD SECRET</p> 113 + <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 + <input 115 + type="text" 116 + id="secret-key" 117 + name="key" 118 + required 119 + placeholder="SECRET_NAME" 120 + /> 121 + <textarea 122 + type="text" 123 + id="secret-value" 124 + name="value" 125 + required 126 + placeholder="secret value"></textarea> 127 + <div class="flex gap-2 pt-2"> 128 + <button 129 + type="button" 130 + popovertarget="add-secret-modal" 131 + popovertargetaction="hide" 132 + 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" 133 + > 134 + {{ i "x" "size-4" }} cancel 135 + </button> 136 + <button type="submit" class="btn w-1/2 flex items-center"> 137 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 138 + <span id="spinner" class="group"> 139 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 140 + </span> 141 + </button> 142 + </div> 143 + <div id="add-secret-error" class="text-red-500 dark:text-red-400"></div> 144 + </form> 145 + {{ end }}
-138
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 }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+27 -34
appview/pages/templates/repo/tree.html
··· 19 19 {{define "repoContent"}} 20 20 <main> 21 21 <div class="tree"> 22 - {{ $containerstyle := "py-1" }} 23 22 {{ $linkstyle := "no-underline hover:underline" }} 24 23 25 24 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> ··· 54 53 </div> 55 54 56 55 {{ range .Files }} 57 - {{ if not .IsFile }} 58 - <div class="{{ $containerstyle }}"> 59 - <div class="flex justify-between items-center"> 60 - <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 61 - <div class="flex items-center gap-2"> 62 - {{ i "folder" "size-4 fill-current" }}{{ .Name }} 63 - </div> 64 - </a> 65 - {{ if .LastCommit}} 66 - <div class="flex items-end gap-2"> 67 - <span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span> 68 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 56 + <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 + <div class="col-span-6 md:col-span-3"> 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 + {{ $icon := "folder" }} 60 + {{ $iconStyle := "size-4 fill-current" }} 61 + 62 + {{ if .IsFile }} 63 + {{ $icon = "file" }} 64 + {{ $iconStyle = "flex-shrink-0 size-4" }} 65 + {{ end }} 66 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 + <div class="flex items-center gap-2"> 68 + {{ i $icon $iconStyle }} 69 + <span class="truncate">{{ .Name }}</span> 69 70 </div> 70 - {{ end }} 71 + </a> 72 + </div> 73 + 74 + <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 75 + {{ with .LastCommit }} 76 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 77 + {{ end }} 71 78 </div> 72 - </div> 73 - {{ end }} 74 - {{ end }} 75 79 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 }} 80 + <div class="col-span-6 md:col-span-2 text-right"> 81 + {{ with .LastCommit }} 82 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 + {{ end }} 91 84 </div> 92 - </div> 85 + </div> 93 86 {{ end }} 94 - {{ end }} 87 + 95 88 </div> 96 89 </main> 97 90 {{end}}
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+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 }}
+88
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="/strings/{{ $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> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + {{ end }} 51 + </span> 52 + </section> 53 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 + <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"> 55 + <span> 56 + {{ .String.Filename }} 57 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 58 + <span> 59 + {{ with .String.Edited }} 60 + edited {{ template "repo/fragments/shortTimeAgo" . }} 61 + {{ else }} 62 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 63 + {{ end }} 64 + </span> 65 + </span> 66 + <div> 67 + <span>{{ .Stats.LineCount }} lines</span> 68 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 69 + <span>{{ byteFmt .Stats.ByteCount }}</span> 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 72 + {{ if .RenderToggle }} 73 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 74 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 75 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 76 + </a> 77 + {{ end }} 78 + </div> 79 + </div> 80 + <div class="overflow-auto relative"> 81 + {{ if .ShowRendered }} 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 + {{ else }} 84 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 + {{ end }} 86 + </div> 87 + </section> 88 + {{ end }}
+2 -2
appview/pages/templates/timeline.html
··· 34 34 </p> 35 35 36 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"> 37 + <a href="/signup" class="no-underline hover:no-underline "> 38 + <button class="btn-create flex gap-2 px-4 items-center"> 39 39 join now {{ i "arrow-right" "size-4" }} 40 40 </button> 41 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 }}
+8 -7
appview/pages/templates/user/fragments/profileCard.html
··· 2 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <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 }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 14 - {{ didOrHandle .UserDid .UserHandle }} 15 - </p> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ didOrHandle .UserDid .UserHandle }} 14 + </p> 15 + <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 16 17 17 18 <div class="md:hidden"> 18 19 {{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+5 -4
appview/pages/templates/user/fragments/repoCard.html
··· 28 28 {{ define "repoStats" }} 29 29 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 30 30 {{ with .Language }} 31 - <div class="flex gap-2 items-center text-sm"> 32 - <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 - <span>{{ . }}</span> 34 - </div> 31 + <div class="flex gap-2 items-center text-sm"> 32 + <div class="size-2 rounded-full" 33 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 34 + <span>{{ . }}</span> 35 + </div> 35 36 {{ end }} 36 37 {{ with .StarCount }} 37 38 <div class="flex gap-1 items-center text-sm">
+13 -34
appview/pages/templates/user/login.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 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 - /> 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" /> 22 10 <script src="/static/htmx.min.js"></script> 23 - <link 24 - rel="stylesheet" 25 - href="/static/tw.css?{{ cssContentHash }}" 26 - type="text/css" 27 - /> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 28 12 <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 35 17 tangled 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 36 + placeholder="akshay.tngl.sh" 54 37 /> 55 38 <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. 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. 60 42 </span> 61 43 </div> 62 44 63 45 <button 64 - class="btn w-full my-2 mt-6" 46 + class="btn w-full my-2 mt-6 text-base " 65 47 type="submit" 66 48 id="login-button" 67 49 tabindex="3" ··· 70 52 </button> 71 53 </form> 72 54 <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 - >. 55 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 78 56 </p> 57 + 79 58 <p id="login-msg" class="error w-full"></p> 80 59 </main> 81 60 </body>
+3 -3
appview/pages/templates/user/repos.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 order-1 md:order-1"> 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 13 {{ template "user/fragments/profileCard" .Card }} 14 14 </div> 15 - <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 15 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 16 {{ block "ownRepos" . }}{{ end }} 17 17 </div> 18 18 </div>
+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 ATProto 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 11 12 12 "tangled.sh/tangled.sh/core/appview/config" 13 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/idresolver" 15 14 "tangled.sh/tangled.sh/core/appview/oauth" 16 15 "tangled.sh/tangled.sh/core/appview/pages" 17 16 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 17 "tangled.sh/tangled.sh/core/eventconsumer" 18 + "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/log" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 22 22 23 23 "github.com/go-chi/chi/v5" 24 24 "github.com/gorilla/websocket" 25 - "github.com/posthog/posthog-go" 26 25 ) 27 26 28 27 type Pipelines struct { ··· 34 33 spindlestream *eventconsumer.Consumer 35 34 db *db.DB 36 35 enforcer *rbac.Enforcer 37 - posthog posthog.Client 38 36 logger *slog.Logger 39 37 } 40 38 ··· 46 44 idResolver *idresolver.Resolver, 47 45 db *db.DB, 48 46 config *config.Config, 49 - posthog posthog.Client, 50 47 enforcer *rbac.Enforcer, 51 48 ) *Pipelines { 52 49 logger := log.New("pipelines") ··· 58 55 config: config, 59 56 spindlestream: spindlestream, 60 57 db: db, 61 - posthog: posthog, 62 58 enforcer: enforcer, 63 59 logger: logger, 64 60 }
+131
appview/posthog/notifier.go
··· 1 + package posthog_service 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.sh/tangled.sh/core/appview/db" 9 + "tangled.sh/tangled.sh/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.OwnerDid, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: follow.UserDid, 104 + Event: "follow", 105 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 + }) 107 + if err != nil { 108 + log.Println("failed to enqueue posthog event:", err) 109 + } 110 + } 111 + 112 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 + err := n.client.Enqueue(posthog.Capture{ 114 + DistinctId: follow.UserDid, 115 + Event: "unfollow", 116 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 + }) 118 + if err != nil { 119 + log.Println("failed to enqueue posthog event:", err) 120 + } 121 + } 122 + 123 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 + err := n.client.Enqueue(posthog.Capture{ 125 + DistinctId: profile.Did, 126 + Event: "edit_profile", 127 + }) 128 + if err != nil { 129 + log.Println("failed to enqueue posthog event:", err) 130 + } 131 + }
+44 -41
appview/pulls/pulls.go
··· 14 14 "time" 15 15 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/config" 19 18 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 19 + "tangled.sh/tangled.sh/core/appview/notify" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/pages" 23 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/tid" 26 27 "tangled.sh/tangled.sh/core/types" 27 28 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" ··· 31 32 lexutil "github.com/bluesky-social/indigo/lex/util" 32 33 "github.com/go-chi/chi/v5" 33 34 "github.com/google/uuid" 34 - "github.com/posthog/posthog-go" 35 35 ) 36 36 37 37 type Pulls struct { ··· 41 41 idResolver *idresolver.Resolver 42 42 db *db.DB 43 43 config *config.Config 44 - posthog posthog.Client 44 + notifier notify.Notifier 45 45 } 46 46 47 47 func New( ··· 51 51 resolver *idresolver.Resolver, 52 52 db *db.DB, 53 53 config *config.Config, 54 - posthog posthog.Client, 54 + notifier notify.Notifier, 55 55 ) *Pulls { 56 56 return &Pulls{ 57 57 oauth: oauth, ··· 60 60 idResolver: resolver, 61 61 db: db, 62 62 config: config, 63 - posthog: posthog, 63 + notifier: notifier, 64 64 } 65 65 } 66 66 ··· 555 555 556 556 // we want to group all stacked PRs into just one list 557 557 stacks := make(map[string]db.Stack) 558 + var shas []string 558 559 n := 0 559 560 for _, p := range pulls { 561 + // store the sha for later 562 + shas = append(shas, p.LatestSha()) 560 563 // this PR is stacked 561 564 if p.StackId != "" { 562 565 // we have already seen this PR stack ··· 575 578 } 576 579 pulls = pulls[:n] 577 580 581 + repoInfo := f.RepoInfo(user) 582 + ps, err := db.GetPipelineStatuses( 583 + s.db, 584 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 585 + db.FilterEq("repo_name", repoInfo.Name), 586 + db.FilterEq("knot", repoInfo.Knot), 587 + db.FilterIn("sha", shas), 588 + ) 589 + if err != nil { 590 + log.Printf("failed to fetch pipeline statuses: %s", err) 591 + // non-fatal 592 + } 593 + m := make(map[string]db.Pipeline) 594 + for _, p := range ps { 595 + m[p.Sha] = p 596 + } 597 + 578 598 identsToResolve := make([]string, len(pulls)) 579 599 for i, pull := range pulls { 580 600 identsToResolve[i] = pull.OwnerDid ··· 596 616 DidHandleMap: didHandleMap, 597 617 FilteringBy: state, 598 618 Stacks: stacks, 619 + Pipelines: m, 599 620 }) 600 - return 601 621 } 602 622 603 623 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 668 688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 669 689 Collection: tangled.RepoPullCommentNSID, 670 690 Repo: user.Did, 671 - Rkey: appview.TID(), 691 + Rkey: tid.TID(), 672 692 Record: &lexutil.LexiconTypeDecoder{ 673 693 Val: &tangled.RepoPullComment{ 674 694 Repo: &atUri, ··· 685 705 return 686 706 } 687 707 688 - // Create the pull comment in the database with the commentAt field 689 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 708 + comment := &db.PullComment{ 690 709 OwnerDid: user.Did, 691 710 RepoAt: f.RepoAt.String(), 692 711 PullId: pull.PullId, 693 712 Body: body, 694 713 CommentAt: atResp.Uri, 695 714 SubmissionId: pull.Submissions[roundNumber].ID, 696 - }) 715 + } 716 + 717 + // Create the pull comment in the database with the commentAt field 718 + commentId, err := db.NewPullComment(tx, comment) 697 719 if err != nil { 698 720 log.Println("failed to create pull comment", err) 699 721 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 707 729 return 708 730 } 709 731 710 - if !s.config.Core.Dev { 711 - err = s.posthog.Enqueue(posthog.Capture{ 712 - DistinctId: user.Did, 713 - Event: "new_pull_comment", 714 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, 715 - }) 716 - if err != nil { 717 - log.Println("failed to enqueue posthog event:", err) 718 - } 719 - } 732 + s.notifier.NewPullComment(r.Context(), comment) 720 733 721 734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 722 735 return ··· 1045 1058 body = formatPatches[0].Body 1046 1059 } 1047 1060 1048 - rkey := appview.TID() 1061 + rkey := tid.TID() 1049 1062 initialSubmission := db.PullSubmission{ 1050 1063 Patch: patch, 1051 1064 SourceRev: sourceRev, 1052 1065 } 1053 - err = db.NewPull(tx, &db.Pull{ 1066 + pull := &db.Pull{ 1054 1067 Title: title, 1055 1068 Body: body, 1056 1069 TargetBranch: targetBranch, ··· 1061 1074 &initialSubmission, 1062 1075 }, 1063 1076 PullSource: pullSource, 1064 - }) 1077 + } 1078 + err = db.NewPull(tx, pull) 1065 1079 if err != nil { 1066 1080 log.Println("failed to create pull request", err) 1067 1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1101 1115 return 1102 1116 } 1103 1117 1104 - if !s.config.Core.Dev { 1105 - err = s.posthog.Enqueue(posthog.Capture{ 1106 - DistinctId: user.Did, 1107 - Event: "new_pull", 1108 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, 1109 - }) 1110 - if err != nil { 1111 - log.Println("failed to enqueue posthog event:", err) 1112 - } 1113 - } 1118 + s.notifier.NewPull(r.Context(), pull) 1114 1119 1115 1120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1116 1121 } ··· 1673 1678 } 1674 1679 1675 1680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1676 - return 1677 1681 } 1678 1682 1679 1683 func (s *Pulls) resubmitStackedPullHelper( ··· 1917 1921 } 1918 1922 1919 1923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1920 - return 1921 1924 } 1922 1925 1923 1926 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2041 2044 2042 2045 // auth filter: only owner or collaborators can close 2043 2046 roles := f.RolesInRepo(user) 2047 + isOwner := roles.IsOwner() 2044 2048 isCollaborator := roles.IsCollaborator() 2045 2049 isPullAuthor := user.Did == pull.OwnerDid 2046 - isCloseAllowed := isCollaborator || isPullAuthor 2050 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2047 2051 if !isCloseAllowed { 2048 2052 log.Println("failed to close pull") 2049 2053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2087 2091 } 2088 2092 2089 2093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2090 - return 2091 2094 } 2092 2095 2093 2096 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2109 2112 2110 2113 // auth filter: only owner or collaborators can close 2111 2114 roles := f.RolesInRepo(user) 2115 + isOwner := roles.IsOwner() 2112 2116 isCollaborator := roles.IsCollaborator() 2113 2117 isPullAuthor := user.Did == pull.OwnerDid 2114 - isCloseAllowed := isCollaborator || isPullAuthor 2118 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2115 2119 if !isCloseAllowed { 2116 2120 log.Println("failed to close pull") 2117 2121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2155 2159 } 2156 2160 2157 2161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2158 - return 2159 2162 } 2160 2163 2161 2164 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2181 2184 2182 2185 title := fp.Title 2183 2186 body := fp.Body 2184 - rkey := appview.TID() 2187 + rkey := tid.TID() 2185 2188 2186 2189 initialSubmission := db.PullSubmission{ 2187 2190 Patch: fp.Raw,
+2
appview/pulls/router.go
··· 44 44 r.Get("/", s.ResubmitPull) 45 45 r.Post("/", s.ResubmitPull) 46 46 }) 47 + // permissions here require us to know pull author 48 + // it is handled within the route 47 49 r.Post("/close", s.ClosePull) 48 50 r.Post("/reopen", s.ReopenPull) 49 51 // collaborators only
+2 -2
appview/repo/artifact.go
··· 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 "github.com/ipfs/go-cid" 16 16 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/db" 19 18 "tangled.sh/tangled.sh/core/appview/pages" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 20 "tangled.sh/tangled.sh/core/knotclient" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 "tangled.sh/tangled.sh/core/types" 23 23 ) 24 24 ··· 64 64 65 65 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 66 67 - rkey := appview.TID() 67 + rkey := tid.TID() 68 68 createdAt := time.Now() 69 69 70 70 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+2
appview/repo/index.go
··· 58 58 tagMap[hash] = append(tagMap[hash], branch.Name) 59 59 } 60 60 61 + sortFiles(result.Files) 62 + 61 63 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 62 64 if a.Name == result.Ref { 63 65 return -1
+458 -137
appview/repo/repo.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log" 11 + "log/slog" 11 12 "net/http" 12 13 "net/url" 13 - "path" 14 + "path/filepath" 14 15 "slices" 15 - "sort" 16 16 "strconv" 17 17 "strings" 18 18 "time" 19 19 20 20 "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 21 "tangled.sh/tangled.sh/core/appview/commitverify" 23 22 "tangled.sh/tangled.sh/core/appview/config" 24 23 "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/idresolver" 24 + "tangled.sh/tangled.sh/core/appview/notify" 26 25 "tangled.sh/tangled.sh/core/appview/oauth" 27 26 "tangled.sh/tangled.sh/core/appview/pages" 28 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 + "tangled.sh/tangled.sh/core/idresolver" 31 31 "tangled.sh/tangled.sh/core/knotclient" 32 32 "tangled.sh/tangled.sh/core/patchutil" 33 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 34 35 "tangled.sh/tangled.sh/core/types" 35 36 36 37 securejoin "github.com/cyphar/filepath-securejoin" 37 38 "github.com/go-chi/chi/v5" 38 39 "github.com/go-git/go-git/v5/plumbing" 39 - "github.com/posthog/posthog-go" 40 40 41 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 42 43 lexutil "github.com/bluesky-social/indigo/lex/util" 43 44 ) 44 45 ··· 51 52 spindlestream *eventconsumer.Consumer 52 53 db *db.DB 53 54 enforcer *rbac.Enforcer 54 - posthog posthog.Client 55 + notifier notify.Notifier 56 + logger *slog.Logger 55 57 } 56 58 57 59 func New( ··· 62 64 idResolver *idresolver.Resolver, 63 65 db *db.DB, 64 66 config *config.Config, 65 - posthog posthog.Client, 67 + notifier notify.Notifier, 66 68 enforcer *rbac.Enforcer, 69 + logger *slog.Logger, 67 70 ) *Repo { 68 71 return &Repo{oauth: oauth, 69 72 repoResolver: repoResolver, ··· 72 75 config: config, 73 76 spindlestream: spindlestream, 74 77 db: db, 75 - posthog: posthog, 78 + notifier: notifier, 76 79 enforcer: enforcer, 80 + logger: logger, 77 81 } 78 82 } 79 83 84 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 85 + refParam := chi.URLParam(r, "ref") 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + log.Println("failed to get repo and knot", err) 89 + return 90 + } 91 + 92 + var uri string 93 + if rp.config.Core.Dev { 94 + uri = "http" 95 + } else { 96 + uri = "https" 97 + } 98 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam)) 99 + 100 + http.Redirect(w, r, url, http.StatusFound) 101 + } 102 + 80 103 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 81 104 f, err := rp.repoResolver.Resolve(r) 82 105 if err != nil { ··· 179 202 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 180 203 RepoInfo: f.RepoInfo(user), 181 204 }) 182 - return 183 205 } 184 206 185 207 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 374 396 375 397 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 376 398 // so we can safely redirect to the "parent" (which is the same file). 377 - if len(result.Files) == 0 && result.Parent == treePath { 399 + unescapedTreePath, _ := url.PathUnescape(treePath) 400 + if len(result.Files) == 0 && result.Parent == unescapedTreePath { 378 401 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 379 402 return 380 403 } ··· 389 412 } 390 413 } 391 414 392 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 393 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 415 + sortFiles(result.Files) 394 416 395 417 rp.pages.RepoTree(w, pages.RepoTreeParams{ 396 418 LoggedInUser: user, 397 419 BreadCrumbs: breadcrumbs, 398 - BaseTreeLink: baseTreeLink, 399 - BaseBlobLink: baseBlobLink, 420 + TreePath: treePath, 400 421 RepoInfo: f.RepoInfo(user), 401 422 RepoTreeResponse: result, 402 423 }) 403 - return 404 424 } 405 425 406 426 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 458 478 ArtifactMap: artifactMap, 459 479 DanglingArtifacts: danglingArtifacts, 460 480 }) 461 - return 462 481 } 463 482 464 483 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 480 499 return 481 500 } 482 501 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 - }) 502 + sortBranches(result.Branches) 499 503 500 504 user := rp.oauth.GetUser(r) 501 505 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ ··· 503 507 RepoInfo: f.RepoInfo(user), 504 508 RepoBranchesResponse: *result, 505 509 }) 506 - return 507 510 } 508 511 509 512 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 554 557 showRendered = r.URL.Query().Get("code") != "true" 555 558 } 556 559 560 + var unsupported bool 561 + var isImage bool 562 + var isVideo bool 563 + var contentSrc string 564 + 565 + if result.IsBinary { 566 + ext := strings.ToLower(filepath.Ext(result.Path)) 567 + switch ext { 568 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 569 + isImage = true 570 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 571 + isVideo = true 572 + default: 573 + unsupported = true 574 + } 575 + 576 + // fetch the actual binary content like in RepoBlobRaw 577 + 578 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 579 + contentSrc = blobURL 580 + if !rp.config.Core.Dev { 581 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 582 + } 583 + } 584 + 557 585 user := rp.oauth.GetUser(r) 558 586 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 559 587 LoggedInUser: user, ··· 562 590 BreadCrumbs: breadcrumbs, 563 591 ShowRendered: showRendered, 564 592 RenderToggle: renderToggle, 593 + Unsupported: unsupported, 594 + IsImage: isImage, 595 + IsVideo: isVideo, 596 + ContentSrc: contentSrc, 565 597 }) 566 - return 567 598 } 568 599 569 600 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 570 601 f, err := rp.repoResolver.Resolve(r) 571 602 if err != nil { 572 603 log.Println("failed to get repo and knot", err) 604 + w.WriteHeader(http.StatusBadRequest) 573 605 return 574 606 } 575 607 ··· 580 612 if !rp.config.Core.Dev { 581 613 protocol = "https" 582 614 } 583 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 615 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 616 + resp, err := http.Get(blobURL) 584 617 if err != nil { 585 - log.Println("failed to reach knotserver", err) 618 + log.Println("failed to reach knotserver:", err) 619 + rp.pages.Error503(w) 586 620 return 587 621 } 622 + defer resp.Body.Close() 588 623 589 - body, err := io.ReadAll(resp.Body) 590 - if err != nil { 591 - log.Printf("Error reading response body: %v", err) 624 + if resp.StatusCode != http.StatusOK { 625 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 626 + w.WriteHeader(resp.StatusCode) 627 + _, _ = io.Copy(w, resp.Body) 592 628 return 593 629 } 594 630 595 - var result types.RepoBlobResponse 596 - err = json.Unmarshal(body, &result) 631 + contentType := resp.Header.Get("Content-Type") 632 + body, err := io.ReadAll(resp.Body) 597 633 if err != nil { 598 - log.Println("failed to parse response:", err) 634 + log.Printf("error reading response body from knotserver: %v", err) 635 + w.WriteHeader(http.StatusInternalServerError) 599 636 return 600 637 } 601 638 602 - if result.IsBinary { 603 - w.Header().Set("Content-Type", "application/octet-stream") 639 + if strings.Contains(contentType, "text/plain") { 640 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 641 + w.Write(body) 642 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 643 + w.Header().Set("Content-Type", contentType) 604 644 w.Write(body) 645 + } else { 646 + w.WriteHeader(http.StatusUnsupportedMediaType) 647 + w.Write([]byte("unsupported content type")) 605 648 return 606 649 } 607 - 608 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 609 - w.Write([]byte(result.Contents)) 610 - return 611 650 } 612 651 613 652 // modify the spindle configured for this repo 614 653 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 654 + user := rp.oauth.GetUser(r) 655 + l := rp.logger.With("handler", "EditSpindle") 656 + l = l.With("did", user.Did) 657 + l = l.With("handle", user.Handle) 658 + 659 + errorId := "operation-error" 660 + fail := func(msg string, err error) { 661 + l.Error(msg, "err", err) 662 + rp.pages.Notice(w, errorId, msg) 663 + } 664 + 615 665 f, err := rp.repoResolver.Resolve(r) 616 666 if err != nil { 617 - log.Println("failed to get repo and knot", err) 618 - w.WriteHeader(http.StatusBadRequest) 667 + fail("Failed to resolve repo. Try again later", err) 619 668 return 620 669 } 621 670 622 671 repoAt := f.RepoAt 623 672 rkey := repoAt.RecordKey().String() 624 673 if rkey == "" { 625 - log.Println("invalid aturi for repo", err) 626 - w.WriteHeader(http.StatusInternalServerError) 674 + fail("Failed to resolve repo. Try again later", err) 627 675 return 628 676 } 629 - 630 - user := rp.oauth.GetUser(r) 631 677 632 678 newSpindle := r.FormValue("spindle") 679 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 633 680 client, err := rp.oauth.AuthorizedClient(r) 634 681 if err != nil { 635 - log.Println("failed to get client") 636 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 682 + fail("Failed to authorize. Try again later.", err) 637 683 return 638 684 } 639 685 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 686 + if !removingSpindle { 687 + // ensure that this is a valid spindle for this user 688 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 689 + if err != nil { 690 + fail("Failed to find spindles. Try again later.", err) 691 + return 692 + } 693 + 694 + if !slices.Contains(validSpindles, newSpindle) { 695 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 696 + return 697 + } 646 698 } 647 699 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 700 + spindlePtr := &newSpindle 701 + if removingSpindle { 702 + spindlePtr = nil 652 703 } 653 704 654 705 // optimistic update 655 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 706 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 656 707 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.") 708 + fail("Failed to update spindle. Try again later.", err) 659 709 return 660 710 } 661 711 662 712 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 663 713 if err != nil { 664 - // failed to get record 665 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 714 + fail("Failed to update spindle, no record found on PDS.", err) 666 715 return 667 716 } 668 717 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 677 726 Owner: user.Did, 678 727 CreatedAt: f.CreatedAt, 679 728 Description: &f.Description, 680 - Spindle: &newSpindle, 729 + Spindle: spindlePtr, 681 730 }, 682 731 }, 683 732 }) 684 733 685 734 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.") 735 + fail("Failed to update spindle, unable to save to PDS.", err) 689 736 return 690 737 } 691 738 692 - // add this spindle to spindle stream 693 - rp.spindlestream.AddSource( 694 - context.Background(), 695 - eventconsumer.NewSpindleSource(newSpindle), 696 - ) 739 + if !removingSpindle { 740 + // add this spindle to spindle stream 741 + rp.spindlestream.AddSource( 742 + context.Background(), 743 + eventconsumer.NewSpindleSource(newSpindle), 744 + ) 745 + } 697 746 698 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 747 + rp.pages.HxRefresh(w) 699 748 } 700 749 701 750 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 751 + user := rp.oauth.GetUser(r) 752 + l := rp.logger.With("handler", "AddCollaborator") 753 + l = l.With("did", user.Did) 754 + l = l.With("handle", user.Handle) 755 + 702 756 f, err := rp.repoResolver.Resolve(r) 703 757 if err != nil { 704 - log.Println("failed to get repo and knot", err) 758 + l.Error("failed to get repo and knot", "err", err) 705 759 return 760 + } 761 + 762 + errorId := "add-collaborator-error" 763 + fail := func(msg string, err error) { 764 + l.Error(msg, "err", err) 765 + rp.pages.Notice(w, errorId, msg) 706 766 } 707 767 708 768 collaborator := r.FormValue("collaborator") 709 769 if collaborator == "" { 710 - http.Error(w, "malformed form", http.StatusBadRequest) 770 + fail("Invalid form.", nil) 711 771 return 712 772 } 773 + 774 + // remove a single leading `@`, to make @handle work with ResolveIdent 775 + collaborator = strings.TrimPrefix(collaborator, "@") 713 776 714 777 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 715 778 if err != nil { 716 - w.Write([]byte("failed to resolve collaborator did to a handle")) 779 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 780 + return 781 + } 782 + 783 + if collaboratorIdent.DID.String() == user.Did { 784 + fail("You seem to be adding yourself as a collaborator.", nil) 717 785 return 718 786 } 719 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 787 + l = l.With("collaborator", collaboratorIdent.Handle) 788 + l = l.With("knot", f.Knot) 720 789 721 - // TODO: create an atproto record for this 790 + // announce this relation into the firehose, store into owners' pds 791 + client, err := rp.oauth.AuthorizedClient(r) 792 + if err != nil { 793 + fail("Failed to write to PDS.", err) 794 + return 795 + } 722 796 797 + // emit a record 798 + currentUser := rp.oauth.GetUser(r) 799 + rkey := tid.TID() 800 + createdAt := time.Now() 801 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 802 + Collection: tangled.RepoCollaboratorNSID, 803 + Repo: currentUser.Did, 804 + Rkey: rkey, 805 + Record: &lexutil.LexiconTypeDecoder{ 806 + Val: &tangled.RepoCollaborator{ 807 + Subject: collaboratorIdent.DID.String(), 808 + Repo: string(f.RepoAt), 809 + CreatedAt: createdAt.Format(time.RFC3339), 810 + }}, 811 + }) 812 + // invalid record 813 + if err != nil { 814 + fail("Failed to write record to PDS.", err) 815 + return 816 + } 817 + l = l.With("at-uri", resp.Uri) 818 + l.Info("wrote record to PDS") 819 + 820 + l.Info("adding to knot") 723 821 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 724 822 if err != nil { 725 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 823 + fail("Failed to add to knot.", err) 726 824 return 727 825 } 728 826 729 827 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 730 828 if err != nil { 731 - log.Println("failed to create client to ", f.Knot) 829 + fail("Failed to add to knot.", err) 732 830 return 733 831 } 734 832 735 833 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 736 834 if err != nil { 737 - log.Printf("failed to make request to %s: %s", f.Knot, err) 835 + fail("Knot was unreachable.", err) 738 836 return 739 837 } 740 838 741 839 if ksResp.StatusCode != http.StatusNoContent { 742 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 840 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 743 841 return 744 842 } 745 843 746 844 tx, err := rp.db.BeginTx(r.Context(), nil) 747 845 if err != nil { 748 - log.Println("failed to start tx") 749 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 846 + fail("Failed to add collaborator.", err) 750 847 return 751 848 } 752 849 defer func() { 753 850 tx.Rollback() 754 851 err = rp.enforcer.E.LoadPolicy() 755 852 if err != nil { 756 - log.Println("failed to rollback policies") 853 + fail("Failed to add collaborator.", err) 757 854 } 758 855 }() 759 856 760 857 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 761 858 if err != nil { 762 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 859 + fail("Failed to add collaborator permissions.", err) 763 860 return 764 861 } 765 862 766 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 863 + err = db.AddCollaborator(rp.db, db.Collaborator{ 864 + Did: syntax.DID(currentUser.Did), 865 + Rkey: rkey, 866 + SubjectDid: collaboratorIdent.DID, 867 + RepoAt: f.RepoAt, 868 + Created: createdAt, 869 + }) 767 870 if err != nil { 768 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 871 + fail("Failed to add collaborator.", err) 769 872 return 770 873 } 771 874 772 875 err = tx.Commit() 773 876 if err != nil { 774 - log.Println("failed to commit changes", err) 775 - http.Error(w, err.Error(), http.StatusInternalServerError) 877 + fail("Failed to add collaborator.", err) 776 878 return 777 879 } 778 880 779 881 err = rp.enforcer.E.SavePolicy() 780 882 if err != nil { 781 - log.Println("failed to update ACLs", err) 782 - http.Error(w, err.Error(), http.StatusInternalServerError) 883 + fail("Failed to update collaborator permissions.", err) 783 884 return 784 885 } 785 886 786 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 787 - 887 + rp.pages.HxRefresh(w) 788 888 } 789 889 790 890 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { ··· 936 1036 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 937 1037 } 938 1038 939 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1039 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1040 + user := rp.oauth.GetUser(r) 1041 + l := rp.logger.With("handler", "Secrets") 1042 + l = l.With("handle", user.Handle) 1043 + l = l.With("did", user.Did) 1044 + 940 1045 f, err := rp.repoResolver.Resolve(r) 941 1046 if err != nil { 942 1047 log.Println("failed to get repo and knot", err) 943 1048 return 944 1049 } 945 1050 1051 + if f.Spindle == "" { 1052 + log.Println("empty spindle cannot add/rm secret", err) 1053 + return 1054 + } 1055 + 1056 + lxm := tangled.RepoAddSecretNSID 1057 + if r.Method == http.MethodDelete { 1058 + lxm = tangled.RepoRemoveSecretNSID 1059 + } 1060 + 1061 + spindleClient, err := rp.oauth.ServiceClient( 1062 + r, 1063 + oauth.WithService(f.Spindle), 1064 + oauth.WithLxm(lxm), 1065 + oauth.WithDev(rp.config.Core.Dev), 1066 + ) 1067 + if err != nil { 1068 + log.Println("failed to create spindle client", err) 1069 + return 1070 + } 1071 + 1072 + key := r.FormValue("key") 1073 + if key == "" { 1074 + w.WriteHeader(http.StatusBadRequest) 1075 + return 1076 + } 1077 + 946 1078 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 - } 1079 + case http.MethodPut: 1080 + errorId := "add-secret-error" 954 1081 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 - } 1082 + value := r.FormValue("value") 1083 + if value == "" { 1084 + w.WriteHeader(http.StatusBadRequest) 1085 + return 961 1086 } 962 1087 963 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1088 + err = tangled.RepoAddSecret( 1089 + r.Context(), 1090 + spindleClient, 1091 + &tangled.RepoAddSecret_Input{ 1092 + Repo: f.RepoAt.String(), 1093 + Key: key, 1094 + Value: value, 1095 + }, 1096 + ) 964 1097 if err != nil { 965 - log.Println("failed to create unsigned client", err) 1098 + l.Error("Failed to add secret.", "err", err) 1099 + rp.pages.Notice(w, errorId, "Failed to add secret.") 966 1100 return 967 1101 } 968 1102 969 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1103 + case http.MethodDelete: 1104 + errorId := "operation-error" 1105 + 1106 + err = tangled.RepoRemoveSecret( 1107 + r.Context(), 1108 + spindleClient, 1109 + &tangled.RepoRemoveSecret_Input{ 1110 + Repo: f.RepoAt.String(), 1111 + Key: key, 1112 + }, 1113 + ) 970 1114 if err != nil { 971 - log.Println("failed to reach knotserver", err) 1115 + l.Error("Failed to delete secret.", "err", err) 1116 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 972 1117 return 973 1118 } 1119 + } 974 1120 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 1121 + rp.pages.HxRefresh(w) 1122 + } 1123 + 1124 + type tab = map[string]any 1125 + 1126 + var ( 1127 + // would be great to have ordered maps right about now 1128 + settingsTabs []tab = []tab{ 1129 + {"Name": "general", "Icon": "sliders-horizontal"}, 1130 + {"Name": "access", "Icon": "users"}, 1131 + {"Name": "pipelines", "Icon": "layers-2"}, 1132 + } 1133 + ) 1134 + 1135 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1136 + tabVal := r.URL.Query().Get("tab") 1137 + if tabVal == "" { 1138 + tabVal = "general" 1139 + } 1140 + 1141 + switch tabVal { 1142 + case "general": 1143 + rp.generalSettings(w, r) 1144 + 1145 + case "access": 1146 + rp.accessSettings(w, r) 1147 + 1148 + case "pipelines": 1149 + rp.pipelineSettings(w, r) 1150 + } 1151 + 1152 + // user := rp.oauth.GetUser(r) 1153 + // repoCollaborators, err := f.Collaborators(r.Context()) 1154 + // if err != nil { 1155 + // log.Println("failed to get collaborators", err) 1156 + // } 1157 + 1158 + // isCollaboratorInviteAllowed := false 1159 + // if user != nil { 1160 + // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1161 + // if err == nil && ok { 1162 + // isCollaboratorInviteAllowed = true 1163 + // } 1164 + // } 1165 + 1166 + // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1167 + // if err != nil { 1168 + // log.Println("failed to create unsigned client", err) 1169 + // return 1170 + // } 1171 + 1172 + // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1173 + // if err != nil { 1174 + // log.Println("failed to reach knotserver", err) 1175 + // return 1176 + // } 1177 + 1178 + // // all spindles that this user is a member of 1179 + // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1180 + // if err != nil { 1181 + // log.Println("failed to fetch spindles", err) 1182 + // return 1183 + // } 1184 + 1185 + // var secrets []*tangled.RepoListSecrets_Secret 1186 + // if f.Spindle != "" { 1187 + // if spindleClient, err := rp.oauth.ServiceClient( 1188 + // r, 1189 + // oauth.WithService(f.Spindle), 1190 + // oauth.WithLxm(tangled.RepoListSecretsNSID), 1191 + // oauth.WithDev(rp.config.Core.Dev), 1192 + // ); err != nil { 1193 + // log.Println("failed to create spindle client", err) 1194 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1195 + // log.Println("failed to fetch secrets", err) 1196 + // } else { 1197 + // secrets = resp.Secrets 1198 + // } 1199 + // } 1200 + 1201 + // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1202 + // LoggedInUser: user, 1203 + // RepoInfo: f.RepoInfo(user), 1204 + // Collaborators: repoCollaborators, 1205 + // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1206 + // Branches: result.Branches, 1207 + // Spindles: spindles, 1208 + // CurrentSpindle: f.Spindle, 1209 + // Secrets: secrets, 1210 + // }) 1211 + } 1212 + 1213 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1214 + f, err := rp.repoResolver.Resolve(r) 1215 + user := rp.oauth.GetUser(r) 1216 + 1217 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1218 + if err != nil { 1219 + log.Println("failed to create unsigned client", err) 1220 + return 1221 + } 1222 + 1223 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1224 + if err != nil { 1225 + log.Println("failed to reach knotserver", err) 1226 + return 1227 + } 1228 + 1229 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1230 + LoggedInUser: user, 1231 + RepoInfo: f.RepoInfo(user), 1232 + Branches: result.Branches, 1233 + Tabs: settingsTabs, 1234 + Tab: "general", 1235 + }) 1236 + } 1237 + 1238 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1239 + f, err := rp.repoResolver.Resolve(r) 1240 + user := rp.oauth.GetUser(r) 1241 + 1242 + repoCollaborators, err := f.Collaborators(r.Context()) 1243 + if err != nil { 1244 + log.Println("failed to get collaborators", err) 1245 + } 1246 + 1247 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1248 + LoggedInUser: user, 1249 + RepoInfo: f.RepoInfo(user), 1250 + Tabs: settingsTabs, 1251 + Tab: "access", 1252 + Collaborators: repoCollaborators, 1253 + }) 1254 + } 1255 + 1256 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1257 + f, err := rp.repoResolver.Resolve(r) 1258 + user := rp.oauth.GetUser(r) 1259 + 1260 + // all spindles that the repo owner is a member of 1261 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1262 + if err != nil { 1263 + log.Println("failed to fetch spindles", err) 1264 + return 1265 + } 1266 + 1267 + var secrets []*tangled.RepoListSecrets_Secret 1268 + if f.Spindle != "" { 1269 + if spindleClient, err := rp.oauth.ServiceClient( 1270 + r, 1271 + oauth.WithService(f.Spindle), 1272 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1273 + oauth.WithDev(rp.config.Core.Dev), 1274 + ); err != nil { 1275 + log.Println("failed to create spindle client", err) 1276 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1277 + log.Println("failed to fetch secrets", err) 1278 + } else { 1279 + secrets = resp.Secrets 980 1280 } 1281 + } 981 1282 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, 1283 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1284 + return strings.Compare(a.Key, b.Key) 1285 + }) 1286 + 1287 + var dids []string 1288 + for _, s := range secrets { 1289 + dids = append(dids, s.CreatedBy) 1290 + } 1291 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1292 + 1293 + // convert to a more manageable form 1294 + var niceSecret []map[string]any 1295 + for id, s := range secrets { 1296 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1297 + niceSecret = append(niceSecret, map[string]any{ 1298 + "Id": id, 1299 + "Key": s.Key, 1300 + "CreatedAt": when, 1301 + "CreatedBy": resolvedIdents[id].Handle.String(), 990 1302 }) 991 1303 } 1304 + 1305 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1306 + LoggedInUser: user, 1307 + RepoInfo: f.RepoInfo(user), 1308 + Tabs: settingsTabs, 1309 + Tab: "pipelines", 1310 + Spindles: spindles, 1311 + CurrentSpindle: f.Spindle, 1312 + Secrets: niceSecret, 1313 + }) 992 1314 } 993 1315 994 1316 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 1108 1430 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1109 1431 sourceAt := f.RepoAt.String() 1110 1432 1111 - rkey := appview.TID() 1433 + rkey := tid.TID() 1112 1434 repo := &db.Repo{ 1113 1435 Did: user.Did, 1114 1436 Name: forkName, ··· 1233 1555 return 1234 1556 } 1235 1557 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 - }) 1558 + 1559 + sortBranches(branches) 1239 1560 1240 1561 var defaultBranch string 1241 1562 for _, b := range branches {
+34
appview/repo/repo_util.go
··· 5 5 "crypto/rand" 6 6 "fmt" 7 7 "math/big" 8 + "slices" 9 + "sort" 10 + "strings" 8 11 9 12 "tangled.sh/tangled.sh/core/appview/db" 10 13 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 + "tangled.sh/tangled.sh/core/types" 11 15 12 16 "github.com/go-git/go-git/v5/plumbing/object" 13 17 ) 18 + 19 + func sortFiles(files []types.NiceTree) { 20 + sort.Slice(files, func(i, j int) bool { 21 + iIsFile := files[i].IsFile 22 + jIsFile := files[j].IsFile 23 + if iIsFile != jIsFile { 24 + return !iIsFile 25 + } 26 + return files[i].Name < files[j].Name 27 + }) 28 + } 29 + 30 + func sortBranches(branches []types.Branch) { 31 + slices.SortFunc(branches, func(a, b types.Branch) int { 32 + if a.IsDefault { 33 + return -1 34 + } 35 + if b.IsDefault { 36 + return 1 37 + } 38 + if a.Commit != nil && b.Commit != nil { 39 + if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 40 + return 1 41 + } else { 42 + return -1 43 + } 44 + } 45 + return strings.Compare(a.Name, b.Name) 46 + }) 47 + } 14 48 15 49 func uniqueEmails(commits []*object.Commit) []string { 16 50 emails := make(map[string]struct{})
+6
appview/repo/router.go
··· 38 38 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 39 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 40 40 41 + // intentionally doesn't use /* as this isn't 42 + // a file path 43 + r.Get("/archive/{ref}", rp.DownloadArchive) 44 + 41 45 r.Route("/fork", func(r chi.Router) { 42 46 r.Use(middleware.AuthMiddleware(rp.oauth)) 43 47 r.Get("/", rp.ForkRepo) ··· 74 78 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 79 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 80 r.Put("/branches/default", rp.SetDefaultBranch) 81 + r.Put("/secrets", rp.Secrets) 82 + r.Delete("/secrets", rp.Secrets) 77 83 }) 78 84 }) 79 85
+5 -4
appview/reporesolver/resolver.go
··· 17 17 "github.com/go-chi/chi/v5" 18 18 "tangled.sh/tangled.sh/core/appview/config" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 20 "tangled.sh/tangled.sh/core/appview/oauth" 22 21 "tangled.sh/tangled.sh/core/appview/pages" 23 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/rbac" 26 26 ) ··· 149 149 for _, item := range repoCollaborators { 150 150 // currently only two roles: owner and member 151 151 var role string 152 - if item[3] == "repo:owner" { 152 + switch item[3] { 153 + case "repo:owner": 153 154 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 155 + case "repo:collaborator": 155 156 role = "collaborator" 156 - } else { 157 + default: 157 158 continue 158 159 } 159 160
+2 -2
appview/settings/settings.go
··· 12 12 13 13 "github.com/go-chi/chi/v5" 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview" 16 15 "tangled.sh/tangled.sh/core/appview/config" 17 16 "tangled.sh/tangled.sh/core/appview/db" 18 17 "tangled.sh/tangled.sh/core/appview/email" 19 18 "tangled.sh/tangled.sh/core/appview/middleware" 20 19 "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 366 366 return 367 367 } 368 368 369 - rkey := appview.TID() 369 + rkey := tid.TID() 370 370 371 371 tx, err := s.Db.Begin() 372 372 if err != nil {
+104
appview/signup/requests.go
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
+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: fmt.Sprintf(`"did=%s"`, 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 + }
+20 -12
appview/spindles/spindles.go
··· 10 10 11 11 "github.com/go-chi/chi/v5" 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 13 "tangled.sh/tangled.sh/core/appview/config" 15 14 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 15 "tangled.sh/tangled.sh/core/appview/middleware" 18 16 "tangled.sh/tangled.sh/core/appview/oauth" 19 17 "tangled.sh/tangled.sh/core/appview/pages" 20 18 verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 + "tangled.sh/tangled.sh/core/idresolver" 21 20 "tangled.sh/tangled.sh/core/rbac" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" ··· 114 114 } 115 115 116 116 identsToResolve := make([]string, len(members)) 117 - for i, member := range members { 118 - identsToResolve[i] = member 119 - } 117 + copy(identsToResolve, members) 120 118 resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 121 119 didHandleMap := make(map[string]string) 122 120 for _, identity := range resolvedIds { ··· 258 256 259 257 // ok 260 258 s.Pages.HxRefresh(w) 261 - return 262 259 } 263 260 264 261 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { ··· 306 303 s.Enforcer.E.LoadPolicy() 307 304 }() 308 305 306 + // remove spindle members first 307 + err = db.RemoveSpindleMember( 308 + tx, 309 + db.FilterEq("did", user.Did), 310 + db.FilterEq("instance", instance), 311 + ) 312 + if err != nil { 313 + l.Error("failed to remove spindle members", "err", err) 314 + fail() 315 + return 316 + } 317 + 309 318 err = db.DeleteSpindle( 310 319 tx, 311 320 db.FilterEq("owner", user.Did), ··· 524 533 s.Enforcer.E.LoadPolicy() 525 534 }() 526 535 527 - rkey := appview.TID() 536 + rkey := tid.TID() 528 537 529 538 // add member to db 530 539 if err = db.AddSpindleMember(tx, db.SpindleMember{ ··· 610 619 611 620 if string(spindles[0].Owner) != user.Did { 612 621 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 613 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 622 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 614 623 return 615 624 } 616 625 617 626 member := r.FormValue("member") 618 627 if member == "" { 619 628 l.Error("empty member") 620 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 629 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 621 630 return 622 631 } 623 632 l = l.With("member", member) ··· 625 634 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 626 635 if err != nil { 627 636 l.Error("failed to resolve member identity to handle", "err", err) 628 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 637 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 629 638 return 630 639 } 631 640 if memberId.Handle.IsInvalidHandle() { 632 641 l.Error("failed to resolve member identity to handle") 633 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 642 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 634 643 return 635 644 } 636 645 ··· 711 720 712 721 // ok 713 722 s.Pages.HxRefresh(w) 714 - return 715 723 }
+13 -26
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/posthog/posthog-go" 11 10 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 12 "tangled.sh/tangled.sh/core/appview/pages" 13 + "tangled.sh/tangled.sh/core/tid" 15 14 ) 16 15 17 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 41 switch r.Method { 43 42 case http.MethodPost: 44 43 createdAt := time.Now().Format(time.RFC3339) 45 - rkey := appview.TID() 44 + rkey := tid.TID() 46 45 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 46 Collection: tangled.GraphFollowNSID, 48 47 Repo: currentUser.Did, ··· 58 57 return 59 58 } 60 59 61 - err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey) 60 + log.Println("created atproto record: ", resp.Uri) 61 + 62 + follow := &db.Follow{ 63 + UserDid: currentUser.Did, 64 + SubjectDid: subjectIdent.DID.String(), 65 + Rkey: rkey, 66 + } 67 + 68 + err = db.AddFollow(s.db, follow) 62 69 if err != nil { 63 70 log.Println("failed to follow", err) 64 71 return 65 72 } 66 73 67 - log.Println("created atproto record: ", resp.Uri) 74 + s.notifier.NewFollow(r.Context(), follow) 68 75 69 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 70 77 UserDid: subjectIdent.DID.String(), 71 78 FollowStatus: db.IsFollowing, 72 79 }) 73 80 74 - if !s.config.Core.Dev { 75 - err = s.posthog.Enqueue(posthog.Capture{ 76 - DistinctId: currentUser.Did, 77 - Event: "follow", 78 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 79 - }) 80 - if err != nil { 81 - log.Println("failed to enqueue posthog event:", err) 82 - } 83 - } 84 - 85 81 return 86 82 case http.MethodDelete: 87 83 // find the record in the db ··· 113 109 FollowStatus: db.IsNotFollowing, 114 110 }) 115 111 116 - if !s.config.Core.Dev { 117 - err = s.posthog.Enqueue(posthog.Capture{ 118 - DistinctId: currentUser.Did, 119 - Event: "unfollow", 120 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 121 - }) 122 - if err != nil { 123 - log.Println("failed to enqueue posthog event:", err) 124 - } 125 - } 112 + s.notifier.DeleteFollow(r.Context(), follow) 126 113 127 114 return 128 115 }
+111 -27
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 4 + "context" 7 5 "fmt" 8 6 "log" 9 7 "net/http" ··· 16 14 "github.com/bluesky-social/indigo/atproto/syntax" 17 15 lexutil "github.com/bluesky-social/indigo/lex/util" 18 16 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 17 + "github.com/gorilla/feeds" 20 18 "tangled.sh/tangled.sh/core/api/tangled" 21 19 "tangled.sh/tangled.sh/core/appview/db" 22 20 "tangled.sh/tangled.sh/core/appview/pages" ··· 143 141 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 144 142 } 145 143 146 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 147 144 s.pages.ProfilePage(w, pages.ProfilePageParams{ 148 145 LoggedInUser: loggedInUser, 149 146 Repos: pinnedRepos, ··· 152 149 Card: pages.ProfileCard{ 153 150 UserDid: ident.DID.String(), 154 151 UserHandle: ident.Handle.String(), 155 - AvatarUri: profileAvatarUri, 156 152 Profile: profile, 157 153 FollowStatus: followStatus, 158 154 Followers: followers, ··· 195 191 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 196 192 } 197 193 198 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 199 - 200 194 s.pages.ReposPage(w, pages.ReposPageParams{ 201 195 LoggedInUser: loggedInUser, 202 196 Repos: repos, ··· 204 198 Card: pages.ProfileCard{ 205 199 UserDid: ident.DID.String(), 206 200 UserHandle: ident.Handle.String(), 207 - AvatarUri: profileAvatarUri, 208 201 Profile: profile, 209 202 FollowStatus: followStatus, 210 203 Followers: followers, ··· 213 206 }) 214 207 } 215 208 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) 209 + func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { 210 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 211 + if !ok { 212 + s.pages.Error404(w) 213 + return nil 214 + } 215 + 216 + feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) 217 + if err != nil { 218 + s.pages.Error500(w) 219 + return nil 220 + } 221 + 222 + return feed 223 + } 224 + 225 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 226 + feed := s.feedFromRequest(w, r) 227 + if feed == nil { 228 + return 229 + } 230 + 231 + atom, err := feed.ToAtom() 232 + if err != nil { 233 + s.pages.Error500(w) 234 + return 235 + } 236 + 237 + w.Header().Set("content-type", "application/atom+xml") 238 + w.Write([]byte(atom)) 239 + } 240 + 241 + func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { 242 + timeline, err := db.MakeProfileTimeline(s.db, did) 243 + if err != nil { 244 + return nil, err 245 + } 246 + 247 + author := &feeds.Author{ 248 + Name: fmt.Sprintf("@%s", handle), 249 + } 250 + feed := &feeds.Feed{ 251 + Title: fmt.Sprintf("timeline feed for %s", author.Name), 252 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, 253 + Items: make([]*feeds.Item, 0), 254 + Updated: time.UnixMilli(0), 255 + Author: author, 256 + } 257 + for _, byMonth := range timeline.ByMonth { 258 + for _, pull := range byMonth.PullEvents.Items { 259 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 260 + if err != nil { 261 + return nil, err 262 + } 263 + feed.Items = append(feed.Items, &feeds.Item{ 264 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 265 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 266 + Created: pull.Created, 267 + Author: author, 268 + }) 269 + for _, submission := range pull.Submissions { 270 + feed.Items = append(feed.Items, &feeds.Item{ 271 + Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), 272 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 273 + Created: submission.Created, 274 + Author: author, 275 + }) 276 + } 277 + } 278 + for _, issue := range byMonth.IssueEvents.Items { 279 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 280 + if err != nil { 281 + return nil, err 282 + } 283 + feed.Items = append(feed.Items, &feeds.Item{ 284 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 285 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 286 + Created: issue.Created, 287 + Author: author, 288 + }) 289 + } 290 + for _, repo := range byMonth.RepoEvents { 291 + var title string 292 + if repo.Source != nil { 293 + id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 294 + if err != nil { 295 + return nil, err 296 + } 297 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) 298 + } else { 299 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 300 + } 301 + feed.Items = append(feed.Items, &feeds.Item{ 302 + Title: title, 303 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, 304 + Created: repo.Repo.Created, 305 + Author: author, 306 + }) 307 + } 308 + } 309 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 310 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 311 + }) 312 + if len(feed.Items) > 0 { 313 + feed.Updated = feed.Items[0].Created 314 + } 315 + 316 + return feed, nil 222 317 } 223 318 224 319 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 266 361 } 267 362 268 363 s.updateProfile(profile, w, r) 269 - return 270 364 } 271 365 272 366 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 306 400 profile.PinnedRepos = pinnedRepos 307 401 308 402 s.updateProfile(profile, w, r) 309 - return 310 403 } 311 404 312 405 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 371 464 return 372 465 } 373 466 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 - } 467 + s.notifier.UpdateProfile(r.Context(), profile) 383 468 384 469 s.pages.HxRedirect(w, "/"+user.Did) 385 - return 386 470 } 387 471 388 472 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+8 -8
appview/state/reaction.go
··· 10 10 11 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 13 "tangled.sh/tangled.sh/core/appview/db" 15 14 "tangled.sh/tangled.sh/core/appview/pages" 15 + "tangled.sh/tangled.sh/core/tid" 16 16 ) 17 17 18 18 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 45 45 switch r.Method { 46 46 case http.MethodPost: 47 47 createdAt := time.Now().Format(time.RFC3339) 48 - rkey := appview.TID() 48 + rkey := tid.TID() 49 49 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 50 Collection: tangled.FeedReactionNSID, 51 51 Repo: currentUser.Did, ··· 77 77 log.Println("created atproto record: ", resp.Uri) 78 78 79 79 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 - ThreadAt: subjectUri, 81 - Kind: reactionKind, 82 - Count: count, 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 83 IsReacted: true, 84 84 }) 85 85 ··· 115 115 } 116 116 117 117 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 - ThreadAt: subjectUri, 119 - Kind: reactionKind, 120 - Count: count, 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 121 IsReacted: false, 122 122 }) 123 123
+36 -7
appview/state/router.go
··· 14 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 15 "tangled.sh/tangled.sh/core/appview/repo" 16 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 17 18 "tangled.sh/tangled.sh/core/appview/spindles" 18 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 19 21 "tangled.sh/tangled.sh/core/log" 20 22 ) 21 23 ··· 66 68 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 67 69 r := chi.NewRouter() 68 70 69 - // strip @ from user 70 - r.Use(middleware.StripLeadingAt) 71 - 72 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 73 72 r.Get("/", s.Profile) 73 + r.Get("/feed.atom", s.AtomFeedPage) 74 74 75 75 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 76 76 r.Use(mw.GoImport()) ··· 135 135 }) 136 136 137 137 r.Mount("/settings", s.SettingsRouter()) 138 + r.Mount("/strings", s.StringsRouter(mw)) 138 139 r.Mount("/knots", s.KnotsRouter(mw)) 139 140 r.Mount("/spindles", s.SpindlesRouter()) 141 + r.Mount("/signup", s.SignupRouter()) 140 142 r.Mount("/", s.OAuthRouter()) 141 143 142 144 r.Get("/keys/{user}", s.Keys) 145 + r.Get("/terms", s.TermsOfService) 146 + r.Get("/privacy", s.PrivacyPolicy) 143 147 144 148 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 145 149 s.pages.Error404(w) ··· 197 201 return knots.Router(mw) 198 202 } 199 203 204 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 205 + logger := log.New("strings") 206 + 207 + strs := &avstrings.Strings{ 208 + Db: s.db, 209 + OAuth: s.oauth, 210 + Pages: s.pages, 211 + Config: s.config, 212 + Enforcer: s.enforcer, 213 + IdResolver: s.idResolver, 214 + Knotstream: s.knotstream, 215 + Logger: logger, 216 + } 217 + 218 + return strs.Router(mw) 219 + } 220 + 200 221 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) 222 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 202 223 return issues.Router(mw) 203 224 } 204 225 205 226 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) 227 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 207 228 return pulls.Router(mw) 208 229 } 209 230 210 231 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) 232 + logger := log.New("repo") 233 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 212 234 return repo.Router(mw) 213 235 } 214 236 215 237 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) 238 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 217 239 return pipes.Router(mw) 218 240 } 241 + 242 + func (s *State) SignupRouter() http.Handler { 243 + logger := log.New("signup") 244 + 245 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 246 + return sig.Router() 247 + }
+15 -29
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "github.com/posthog/posthog-go" 12 11 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 12 "tangled.sh/tangled.sh/core/appview/db" 15 13 "tangled.sh/tangled.sh/core/appview/pages" 14 + "tangled.sh/tangled.sh/core/tid" 16 15 ) 17 16 18 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 38 switch r.Method { 40 39 case http.MethodPost: 41 40 createdAt := time.Now().Format(time.RFC3339) 42 - rkey := appview.TID() 41 + rkey := tid.TID() 43 42 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 43 Collection: tangled.FeedStarNSID, 45 44 Repo: currentUser.Did, ··· 54 53 log.Println("failed to create atproto record", err) 55 54 return 56 55 } 56 + log.Println("created atproto record: ", resp.Uri) 57 57 58 - err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey) 58 + star := &db.Star{ 59 + StarredByDid: currentUser.Did, 60 + RepoAt: subjectUri, 61 + Rkey: rkey, 62 + } 63 + 64 + err = db.AddStar(s.db, star) 59 65 if err != nil { 60 66 log.Println("failed to star", err) 61 67 return ··· 66 72 log.Println("failed to get star count for ", subjectUri) 67 73 } 68 74 69 - log.Println("created atproto record: ", resp.Uri) 75 + s.notifier.NewStar(r.Context(), star) 70 76 71 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 77 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 72 78 IsStarred: true, 73 79 RepoAt: subjectUri, 74 80 Stats: db.RepoStats{ ··· 76 82 }, 77 83 }) 78 84 79 - if !s.config.Core.Dev { 80 - err = s.posthog.Enqueue(posthog.Capture{ 81 - DistinctId: currentUser.Did, 82 - Event: "star", 83 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 84 - }) 85 - if err != nil { 86 - log.Println("failed to enqueue posthog event:", err) 87 - } 88 - } 89 - 90 85 return 91 86 case http.MethodDelete: 92 87 // find the record in the db ··· 119 114 return 120 115 } 121 116 122 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 117 + s.notifier.DeleteStar(r.Context(), star) 118 + 119 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 123 120 IsStarred: false, 124 121 RepoAt: subjectUri, 125 122 Stats: db.RepoStats{ 126 123 StarCount: starCount, 127 124 }, 128 125 }) 129 - 130 - if !s.config.Core.Dev { 131 - err = s.posthog.Enqueue(posthog.Capture{ 132 - DistinctId: currentUser.Did, 133 - Event: "unstar", 134 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 135 - }) 136 - if err != nil { 137 - log.Println("failed to enqueue posthog event:", err) 138 - } 139 - } 140 126 141 127 return 142 128 }
+28 -356
appview/state/state.go
··· 10 10 "time" 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 13 lexutil "github.com/bluesky-social/indigo/lex/util" 15 14 securejoin "github.com/cyphar/filepath-securejoin" 16 15 "github.com/go-chi/chi/v5" ··· 21 20 "tangled.sh/tangled.sh/core/appview/cache/session" 22 21 "tangled.sh/tangled.sh/core/appview/config" 23 22 "tangled.sh/tangled.sh/core/appview/db" 24 - "tangled.sh/tangled.sh/core/appview/idresolver" 23 + "tangled.sh/tangled.sh/core/appview/notify" 25 24 "tangled.sh/tangled.sh/core/appview/oauth" 26 25 "tangled.sh/tangled.sh/core/appview/pages" 26 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 + "tangled.sh/tangled.sh/core/idresolver" 29 30 "tangled.sh/tangled.sh/core/jetstream" 30 31 "tangled.sh/tangled.sh/core/knotclient" 31 32 tlog "tangled.sh/tangled.sh/core/log" 32 33 "tangled.sh/tangled.sh/core/rbac" 34 + "tangled.sh/tangled.sh/core/tid" 33 35 ) 34 36 35 37 type State struct { 36 38 db *db.DB 39 + notifier notify.Notifier 37 40 oauth *oauth.OAuth 38 41 enforcer *rbac.Enforcer 39 - tidClock syntax.TIDClock 40 42 pages *pages.Pages 41 43 sess *session.SessionStore 42 44 idResolver *idresolver.Resolver ··· 59 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 60 62 } 61 63 62 - clock := syntax.NewTIDClock(0) 63 - 64 64 pgs := pages.NewPages(config) 65 65 66 - res, err := idresolver.RedisResolver(config.Redis) 66 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 67 if err != nil { 68 68 log.Printf("failed to create redis resolver: %v", err) 69 69 res = idresolver.DefaultResolver() ··· 93 93 tangled.ActorProfileNSID, 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 96 97 }, 97 98 nil, 98 99 slog.Default(), ··· 131 132 } 132 133 spindlestream.Start(ctx) 133 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 + 134 141 state := &State{ 135 142 d, 143 + notifier, 136 144 oauth, 137 145 enforcer, 138 - clock, 139 146 pgs, 140 147 sess, 141 148 res, ··· 150 157 return state, nil 151 158 } 152 159 153 - func TID(c *syntax.TIDClock) string { 154 - return c.Next().String() 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 + }) 155 172 } 156 173 157 174 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 198 215 return 199 216 } 200 217 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 218 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 238 219 user := chi.URLParam(r, "user") 239 220 user = strings.TrimPrefix(user, "@") ··· 266 247 } 267 248 } 268 249 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 250 func validateRepoName(name string) error { 570 251 // check for path traversal attempts 571 252 if name == "." || name == ".." || ··· 664 345 return 665 346 } 666 347 667 - rkey := appview.TID() 348 + rkey := tid.TID() 668 349 repo := &db.Repo{ 669 350 Did: user.Did, 670 351 Name: repoName, ··· 760 441 return 761 442 } 762 443 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 - } 444 + s.notifier.NewRepo(r.Context(), repo) 773 445 774 446 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 775 447 return
+6
appview/state/userutil/userutil.go
··· 51 51 func IsDid(s string) bool { 52 52 return didRegex.MatchString(s) 53 53 } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 + }
+454
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("string not found") 104 + s.Pages.Error404(w) 105 + return 106 + } 107 + if len(strings) != 1 { 108 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 109 + w.WriteHeader(http.StatusInternalServerError) 110 + return 111 + } 112 + string := strings[0] 113 + 114 + if path.Base(r.URL.Path) == "raw" { 115 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 116 + if string.Filename != "" { 117 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 118 + } 119 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 120 + 121 + _, err = w.Write([]byte(string.Contents)) 122 + if err != nil { 123 + l.Error("failed to write raw response", "err", err) 124 + } 125 + return 126 + } 127 + 128 + var showRendered, renderToggle bool 129 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 130 + renderToggle = true 131 + showRendered = r.URL.Query().Get("code") != "true" 132 + } 133 + 134 + s.Pages.SingleString(w, pages.SingleStringParams{ 135 + LoggedInUser: s.OAuth.GetUser(r), 136 + RenderToggle: renderToggle, 137 + ShowRendered: showRendered, 138 + String: string, 139 + Stats: string.Stats(), 140 + Owner: id, 141 + }) 142 + } 143 + 144 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 145 + l := s.Logger.With("handler", "dashboard") 146 + 147 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 148 + if !ok { 149 + l.Error("malformed middleware") 150 + w.WriteHeader(http.StatusInternalServerError) 151 + return 152 + } 153 + l = l.With("did", id.DID, "handle", id.Handle) 154 + 155 + all, err := db.GetStrings( 156 + s.Db, 157 + db.FilterEq("did", id.DID), 158 + ) 159 + if err != nil { 160 + l.Error("failed to fetch strings", "err", err) 161 + w.WriteHeader(http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + slices.SortFunc(all, func(a, b db.String) int { 166 + if a.Created.After(b.Created) { 167 + return -1 168 + } else { 169 + return 1 170 + } 171 + }) 172 + 173 + profile, err := db.GetProfile(s.Db, id.DID.String()) 174 + if err != nil { 175 + l.Error("failed to fetch user profile", "err", err) 176 + w.WriteHeader(http.StatusInternalServerError) 177 + return 178 + } 179 + loggedInUser := s.OAuth.GetUser(r) 180 + followStatus := db.IsNotFollowing 181 + if loggedInUser != nil { 182 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 183 + } 184 + 185 + followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 186 + if err != nil { 187 + l.Error("failed to get follow stats", "err", err) 188 + } 189 + 190 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 191 + LoggedInUser: s.OAuth.GetUser(r), 192 + Card: pages.ProfileCard{ 193 + UserDid: id.DID.String(), 194 + UserHandle: id.Handle.String(), 195 + Profile: profile, 196 + FollowStatus: followStatus, 197 + Followers: followers, 198 + Following: following, 199 + }, 200 + Strings: all, 201 + }) 202 + } 203 + 204 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 205 + l := s.Logger.With("handler", "edit") 206 + 207 + user := s.OAuth.GetUser(r) 208 + 209 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 210 + if !ok { 211 + l.Error("malformed middleware") 212 + w.WriteHeader(http.StatusInternalServerError) 213 + return 214 + } 215 + l = l.With("did", id.DID, "handle", id.Handle) 216 + 217 + rkey := chi.URLParam(r, "rkey") 218 + if rkey == "" { 219 + l.Error("malformed url, empty rkey") 220 + w.WriteHeader(http.StatusBadRequest) 221 + return 222 + } 223 + l = l.With("rkey", rkey) 224 + 225 + // get the string currently being edited 226 + all, err := db.GetStrings( 227 + s.Db, 228 + db.FilterEq("did", id.DID), 229 + db.FilterEq("rkey", rkey), 230 + ) 231 + if err != nil { 232 + l.Error("failed to fetch string", "err", err) 233 + w.WriteHeader(http.StatusInternalServerError) 234 + return 235 + } 236 + if len(all) != 1 { 237 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 238 + w.WriteHeader(http.StatusInternalServerError) 239 + return 240 + } 241 + first := all[0] 242 + 243 + // verify that the logged in user owns this string 244 + if user.Did != id.DID.String() { 245 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 246 + w.WriteHeader(http.StatusUnauthorized) 247 + return 248 + } 249 + 250 + switch r.Method { 251 + case http.MethodGet: 252 + // return the form with prefilled fields 253 + s.Pages.PutString(w, pages.PutStringParams{ 254 + LoggedInUser: s.OAuth.GetUser(r), 255 + Action: "edit", 256 + String: first, 257 + }) 258 + case http.MethodPost: 259 + fail := func(msg string, err error) { 260 + l.Error(msg, "err", err) 261 + s.Pages.Notice(w, "error", msg) 262 + } 263 + 264 + filename := r.FormValue("filename") 265 + if filename == "" { 266 + fail("Empty filename.", nil) 267 + return 268 + } 269 + if !strings.Contains(filename, ".") { 270 + // TODO: make this a htmx form validation 271 + fail("No extension provided for filename.", nil) 272 + return 273 + } 274 + 275 + content := r.FormValue("content") 276 + if content == "" { 277 + fail("Empty contents.", nil) 278 + return 279 + } 280 + 281 + description := r.FormValue("description") 282 + 283 + // construct new string from form values 284 + entry := db.String{ 285 + Did: first.Did, 286 + Rkey: first.Rkey, 287 + Filename: filename, 288 + Description: description, 289 + Contents: content, 290 + Created: first.Created, 291 + } 292 + 293 + record := entry.AsRecord() 294 + 295 + client, err := s.OAuth.AuthorizedClient(r) 296 + if err != nil { 297 + fail("Failed to create record.", err) 298 + return 299 + } 300 + 301 + // first replace the existing record in the PDS 302 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 303 + if err != nil { 304 + fail("Failed to updated existing record.", err) 305 + return 306 + } 307 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 308 + Collection: tangled.StringNSID, 309 + Repo: entry.Did.String(), 310 + Rkey: entry.Rkey, 311 + SwapRecord: ex.Cid, 312 + Record: &lexutil.LexiconTypeDecoder{ 313 + Val: &record, 314 + }, 315 + }) 316 + if err != nil { 317 + fail("Failed to updated existing record.", err) 318 + return 319 + } 320 + l := l.With("aturi", resp.Uri) 321 + l.Info("edited string") 322 + 323 + // if that went okay, updated the db 324 + if err = db.AddString(s.Db, entry); err != nil { 325 + fail("Failed to update string.", err) 326 + return 327 + } 328 + 329 + // if that went okay, redir to the string 330 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 331 + } 332 + 333 + } 334 + 335 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 336 + l := s.Logger.With("handler", "create") 337 + user := s.OAuth.GetUser(r) 338 + 339 + switch r.Method { 340 + case http.MethodGet: 341 + s.Pages.PutString(w, pages.PutStringParams{ 342 + LoggedInUser: s.OAuth.GetUser(r), 343 + Action: "new", 344 + }) 345 + case http.MethodPost: 346 + fail := func(msg string, err error) { 347 + l.Error(msg, "err", err) 348 + s.Pages.Notice(w, "error", msg) 349 + } 350 + 351 + filename := r.FormValue("filename") 352 + if filename == "" { 353 + fail("Empty filename.", nil) 354 + return 355 + } 356 + if !strings.Contains(filename, ".") { 357 + // TODO: make this a htmx form validation 358 + fail("No extension provided for filename.", nil) 359 + return 360 + } 361 + 362 + content := r.FormValue("content") 363 + if content == "" { 364 + fail("Empty contents.", nil) 365 + return 366 + } 367 + 368 + description := r.FormValue("description") 369 + 370 + string := db.String{ 371 + Did: syntax.DID(user.Did), 372 + Rkey: tid.TID(), 373 + Filename: filename, 374 + Description: description, 375 + Contents: content, 376 + Created: time.Now(), 377 + } 378 + 379 + record := string.AsRecord() 380 + 381 + client, err := s.OAuth.AuthorizedClient(r) 382 + if err != nil { 383 + fail("Failed to create record.", err) 384 + return 385 + } 386 + 387 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 388 + Collection: tangled.StringNSID, 389 + Repo: user.Did, 390 + Rkey: string.Rkey, 391 + Record: &lexutil.LexiconTypeDecoder{ 392 + Val: &record, 393 + }, 394 + }) 395 + if err != nil { 396 + fail("Failed to create record.", err) 397 + return 398 + } 399 + l := l.With("aturi", resp.Uri) 400 + l.Info("created record") 401 + 402 + // insert into DB 403 + if err = db.AddString(s.Db, string); err != nil { 404 + fail("Failed to create string.", err) 405 + return 406 + } 407 + 408 + // successful 409 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 410 + } 411 + } 412 + 413 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 414 + l := s.Logger.With("handler", "create") 415 + user := s.OAuth.GetUser(r) 416 + fail := func(msg string, err error) { 417 + l.Error(msg, "err", err) 418 + s.Pages.Notice(w, "error", msg) 419 + } 420 + 421 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 422 + if !ok { 423 + l.Error("malformed middleware") 424 + w.WriteHeader(http.StatusInternalServerError) 425 + return 426 + } 427 + l = l.With("did", id.DID, "handle", id.Handle) 428 + 429 + rkey := chi.URLParam(r, "rkey") 430 + if rkey == "" { 431 + l.Error("malformed url, empty rkey") 432 + w.WriteHeader(http.StatusBadRequest) 433 + return 434 + } 435 + 436 + if user.Did != id.DID.String() { 437 + fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 + return 439 + } 440 + 441 + if err := db.DeleteString( 442 + s.Db, 443 + db.FilterEq("did", user.Did), 444 + db.FilterEq("rkey", rkey), 445 + ); err != nil { 446 + fail("Failed to delete string.", err) 447 + return 448 + } 449 + 450 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 451 + } 452 + 453 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 454 + }
-11
appview/tid.go
··· 1 - package appview 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/syntax" 5 - ) 6 - 7 - var c syntax.TIDClock = syntax.NewTIDClock(0) 8 - 9 - func TID() string { 10 - return c.Next().String() 11 - }
+15
appview/xrpcclient/xrpc.go
··· 87 87 88 88 return &out, nil 89 89 } 90 + 91 + func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 92 + var out atproto.ServerGetServiceAuth_Output 93 + 94 + params := map[string]interface{}{ 95 + "aud": aud, 96 + "exp": exp, 97 + "lxm": lxm, 98 + } 99 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 100 + return nil, err 101 + } 102 + 103 + return &out, nil 104 + }
+33 -4
avatar/src/index.js
··· 1 1 export default { 2 2 async fetch(request, env) { 3 + // Helper function to generate a color from a string 4 + const stringToColor = (str) => { 5 + let hash = 0; 6 + for (let i = 0; i < str.length; i++) { 7 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 8 + } 9 + let color = "#"; 10 + for (let i = 0; i < 3; i++) { 11 + const value = (hash >> (i * 8)) & 0xff; 12 + color += ("00" + value.toString(16)).substr(-2); 13 + } 14 + return color; 15 + }; 16 + 3 17 const url = new URL(request.url); 4 18 const { pathname, searchParams } = url; 5 19 ··· 60 74 const profile = await profileResponse.json(); 61 75 const avatar = profile.avatar; 62 76 63 - if (!avatar) { 64 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 77 + let avatarUrl = profile.avatar; 78 + 79 + if (!avatarUrl) { 80 + // Generate a random color based on the actor string 81 + const bgColor = stringToColor(actor); 82 + const size = resizeToTiny ? 32 : 128; 83 + const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`; 84 + const svgData = new TextEncoder().encode(svg); 85 + 86 + response = new Response(svgData, { 87 + headers: { 88 + "Content-Type": "image/svg+xml", 89 + "Cache-Control": "public, max-age=43200", 90 + }, 91 + }); 92 + await cache.put(cacheKey, response.clone()); 93 + return response; 65 94 } 66 95 67 96 // Resize if requested 68 97 let avatarResponse; 69 98 if (resizeToTiny) { 70 - avatarResponse = await fetch(avatar, { 99 + avatarResponse = await fetch(avatarUrl, { 71 100 cf: { 72 101 image: { 73 102 width: 32, ··· 78 107 }, 79 108 }); 80 109 } else { 81 - avatarResponse = await fetch(avatar); 110 + avatarResponse = await fetch(avatarUrl); 82 111 } 83 112 84 113 if (!avatarResponse.ok) {
+2
cmd/gen.go
··· 40 40 tangled.PublicKey{}, 41 41 tangled.Repo{}, 42 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 43 44 tangled.RepoIssue{}, 44 45 tangled.RepoIssueComment{}, 45 46 tangled.RepoIssueState{}, ··· 49 50 tangled.RepoPullStatus{}, 50 51 tangled.Spindle{}, 51 52 tangled.SpindleMember{}, 53 + tangled.String{}, 52 54 ); err != nil { 53 55 panic(err) 54 56 }
+8 -15
docs/contributing.md
··· 115 115 If you're submitting a PR with multiple commits, make sure each one is 116 116 signed. 117 117 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 118 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 119 + to make it sign off commits in the tangled repo: 120 120 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 121 + ```shell 122 + # Safety check, should say "No matching config key..." 123 + jj config list templates.commit_trailers 124 + # The command below may need to be adjusted if the command above returned something. 125 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 126 ``` 134 127 135 128 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 129 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 130 for more information.
+46 -3
docs/hacking.md
··· 32 32 nix run .#watch-tailwind 33 33 ``` 34 34 35 + To authenticate with the appview, you will need redis and 36 + OAUTH JWKs to be setup: 37 + 38 + ``` 39 + # oauth jwks should already be setup by the nix devshell: 40 + echo $TANGLED_OAUTH_JWKS 41 + {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 42 + 43 + # if not, you can set it up yourself: 44 + go build -o genjwks.out ./cmd/genjwks 45 + export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 46 + 47 + # run redis in at a new shell to store oauth sessions 48 + redis-server 49 + ``` 50 + 35 51 ## running a knot 36 52 37 53 An end-to-end knot setup requires setting up a machine with ··· 39 55 quite cumbersome. So the nix flake provides a 40 56 `nixosConfiguration` to do so. 41 57 42 - To begin, head to `http://localhost:3000` in the browser and 43 - generate a knot secret. Replace the existing secret in 44 - `flake.nix` with the newly generated secret. 58 + To begin, head to `http://localhost:3000/knots` in the browser 59 + and create a knot with hostname `localhost:6000`. This will 60 + generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 + don't lose it. 45 63 46 64 You can now start a lightweight NixOS VM using 47 65 `nixos-shell` like so: ··· 71 89 git remote add local-dev git@nixos-shell:user/repo 72 90 git push local-dev main 73 91 ``` 92 + 93 + ## running a spindle 94 + 95 + Be sure to set `$TANGLED_VM_SPINDLE_OWNER` to your own DID. 96 + The above VM should already be running a spindle on `localhost:6555`. 97 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 98 + and register a spindle with hostname `localhost:6555`. It should instantly 99 + be verified. You can then configure each repository to use this spindle 100 + 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 + ```
+23 -5
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 59 59 EOF 60 60 ``` 61 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 62 68 Next, create the `git` user. We'll use the `git` user's home directory 63 69 to store repositories: 64 70 ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 + [/knots](https://tangled.sh/knots) page on Tangled. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git ··· 89 95 systemctl start knotserver 90 96 ``` 91 97 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 98 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 93 99 knot. Here's an example configuration for Nginx: 94 100 95 101 ``` ··· 123 129 knot domain. 124 130 125 131 You should now have a running knot server! You can finalize your registration by hitting the 126 - `initialize` button on the [/knots](/knots) page. 132 + `initialize` button on the [/knots](https://tangled.sh/knots) page. 127 133 128 134 ### custom paths 129 135 ··· 191 197 ``` 192 198 193 199 Make sure to restart your SSH server! 200 + 201 + #### MOTD (message of the day) 202 + 203 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 204 + `/home/git/motd` file: 205 + 206 + ``` 207 + printf "Hi from this knot!\n" > /home/git/motd 208 + ``` 209 + 210 + Note that you should add a newline at the end if setting a non-empty message 211 + since the knot won't do this for you.
+4 -3
docs/spindle/architecture.md
··· 13 13 14 14 ### the engine 15 15 16 - At present, the only supported backend is Docker. Spindle executes each step in 17 - the pipeline in a fresh container, with state persisted across steps within the 18 - `/tangled/workspace` directory. 16 + At present, the only supported backend is Docker (and Podman, if Docker 17 + compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 + executes each step in the pipeline in a fresh container, with state persisted 19 + across steps within the `/tangled/workspace` directory. 19 20 20 21 The base image for the container is constructed on the fly using 21 22 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
+285
docs/spindle/openbao.md
··· 1 + # spindle secrets with openbao 2 + 3 + This document covers setting up Spindle to use OpenBao for secrets 4 + management via OpenBao Proxy instead of the default SQLite backend. 5 + 6 + ## overview 7 + 8 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 + authentication automatically using AppRole credentials, while Spindle 10 + connects to the local proxy instead of directly to the OpenBao server. 11 + 12 + This approach provides better security, automatic token renewal, and 13 + simplified application code. 14 + 15 + ## installation 16 + 17 + Install OpenBao from nixpkgs: 18 + 19 + ```bash 20 + nix shell nixpkgs#openbao # for a local server 21 + ``` 22 + 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 28 + 29 + Start OpenBao in dev mode: 30 + 31 + ```bash 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 + ``` 34 + 35 + This starts OpenBao on `http://localhost:8201` with a root token. 36 + 37 + Set up environment for bao CLI: 38 + 39 + ```bash 40 + export BAO_ADDR=http://localhost:8200 41 + export BAO_TOKEN=root 42 + ``` 43 + 44 + ### production 45 + 46 + You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 + achieved using Nix. 49 + 50 + Then, initialize the bao server: 51 + ```bash 52 + bao operator init -key-shares=1 -key-threshold=1 53 + ``` 54 + 55 + This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 + ```bash 57 + bao operator unseal <unseal_key> 58 + ``` 59 + 60 + All steps below remain the same across both dev and production setups. 61 + 62 + ### configure openbao server 63 + 64 + Create the spindle KV mount: 65 + 66 + ```bash 67 + bao secrets enable -path=spindle -version=2 kv 68 + ``` 69 + 70 + Set up AppRole authentication and policy: 71 + 72 + Create a policy file `spindle-policy.hcl`: 73 + 74 + ```hcl 75 + # Full access to spindle KV v2 data 76 + path "spindle/data/*" { 77 + capabilities = ["create", "read", "update", "delete"] 78 + } 79 + 80 + # Access to metadata for listing and management 81 + path "spindle/metadata/*" { 82 + capabilities = ["list", "read", "delete", "update"] 83 + } 84 + 85 + # Allow listing at root level 86 + path "spindle/" { 87 + capabilities = ["list"] 88 + } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 94 + ``` 95 + 96 + Apply the policy and create an AppRole: 97 + 98 + ```bash 99 + bao policy write spindle-policy spindle-policy.hcl 100 + bao auth enable approle 101 + bao write auth/approle/role/spindle \ 102 + token_policies="spindle-policy" \ 103 + token_ttl=1h \ 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 108 + ``` 109 + 110 + Get the credentials: 111 + 112 + ```bash 113 + # Get role ID (static) 114 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 + 116 + # Generate secret ID 117 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 + 119 + echo "Role ID: $ROLE_ID" 120 + echo "Secret ID: $SECRET_ID" 121 + ``` 122 + 123 + ### create proxy configuration 124 + 125 + Create the credential files: 126 + 127 + ```bash 128 + # Create directory for OpenBao files 129 + mkdir -p /tmp/openbao 130 + 131 + # Save credentials 132 + echo "$ROLE_ID" > /tmp/openbao/role-id 133 + echo "$SECRET_ID" > /tmp/openbao/secret-id 134 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 + ``` 136 + 137 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 + 139 + ```hcl 140 + # OpenBao server connection 141 + vault { 142 + address = "http://localhost:8200" 143 + } 144 + 145 + # Auto-Auth using AppRole 146 + auto_auth { 147 + method "approle" { 148 + mount_path = "auth/approle" 149 + config = { 150 + role_id_file_path = "/tmp/openbao/role-id" 151 + secret_id_file_path = "/tmp/openbao/secret-id" 152 + } 153 + } 154 + 155 + # Optional: write token to file for debugging 156 + sink "file" { 157 + config = { 158 + path = "/tmp/openbao/token" 159 + mode = 0640 160 + } 161 + } 162 + } 163 + 164 + # Proxy listener for Spindle 165 + listener "tcp" { 166 + address = "127.0.0.1:8201" 167 + tls_disable = true 168 + } 169 + 170 + # Enable API proxy with auto-auth token 171 + api_proxy { 172 + use_auto_auth_token = true 173 + } 174 + 175 + # Enable response caching 176 + cache { 177 + use_auto_auth_token = true 178 + } 179 + 180 + # Logging 181 + log_level = "info" 182 + ``` 183 + 184 + ### start the proxy 185 + 186 + Start OpenBao Proxy: 187 + 188 + ```bash 189 + bao proxy -config=/tmp/openbao/proxy.hcl 190 + ``` 191 + 192 + The proxy will authenticate with OpenBao and start listening on 193 + `127.0.0.1:8201`. 194 + 195 + ### configure spindle 196 + 197 + Set these environment variables for Spindle: 198 + 199 + ```bash 200 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 + ``` 204 + 205 + Start Spindle: 206 + 207 + Spindle will now connect to the local proxy, which handles all 208 + authentication automatically. 209 + 210 + ## production setup for proxy 211 + 212 + For production, you'll want to run the proxy as a service: 213 + 214 + Place your production configuration in `/etc/openbao/proxy.hcl` with 215 + proper TLS settings for the vault connection. 216 + 217 + ## verifying setup 218 + 219 + Test the proxy directly: 220 + 221 + ```bash 222 + # Check proxy health 223 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 + 225 + # Test token lookup through proxy 226 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 + ``` 228 + 229 + Test OpenBao operations through the server: 230 + 231 + ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 236 + bao kv list spindle/repos/ 237 + 238 + # Get a specific secret 239 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 + ``` 241 + 242 + ## how it works 243 + 244 + - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 + - The proxy authenticates with OpenBao using AppRole credentials 246 + - All Spindle requests go through the proxy, which injects authentication tokens 247 + - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 + - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 + - The proxy handles all token renewal automatically 250 + - Spindle no longer manages tokens or authentication directly 251 + 252 + ## troubleshooting 253 + 254 + **Connection refused**: Check that the OpenBao Proxy is running and 255 + listening on the configured address. 256 + 257 + **403 errors**: Verify the AppRole credentials are correct and the policy 258 + has the necessary permissions. 259 + 260 + **404 route errors**: The spindle KV mount probably doesn't exist - run 261 + the mount creation step again. 262 + 263 + **Proxy authentication failures**: Check the proxy logs and verify the 264 + role-id and secret-id files are readable and contain valid credentials. 265 + 266 + **Secret not found after writing**: This can indicate policy permission 267 + issues. Verify the policy includes both `spindle/data/*` and 268 + `spindle/metadata/*` paths with appropriate capabilities. 269 + 270 + Check proxy logs: 271 + 272 + ```bash 273 + # If running as systemd service 274 + journalctl -u openbao-proxy -f 275 + 276 + # If running directly, check the console output 277 + ``` 278 + 279 + Test AppRole authentication manually: 280 + 281 + ```bash 282 + bao write auth/approle/login \ 283 + role_id="$(cat /tmp/openbao/role-id)" \ 284 + secret_id="$(cat /tmp/openbao/secret-id)" 285 + ```
+7
docs/spindle/pipeline.md
··· 57 57 depth: 50 58 58 submodules: true 59 59 ``` 60 + 61 + ## git push options 62 + 63 + These are push options that can be used with the `--push-option (-o)` flag of git push: 64 + 65 + - `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push. 66 + - `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+10 -31
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "gitignore": { 4 - "inputs": { 5 - "nixpkgs": [ 6 - "nixpkgs" 7 - ] 8 - }, 9 - "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 15 - "type": "github" 16 - }, 17 - "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 20 - "type": "github" 21 - } 22 - }, 23 3 "flake-utils": { 24 4 "inputs": { 25 5 "systems": "systems" ··· 46 26 ] 47 27 }, 48 28 "locked": { 49 - "lastModified": 1751702058, 50 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 51 31 "owner": "nix-community", 52 32 "repo": "gomod2nix", 53 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 54 34 "type": "github" 55 35 }, 56 36 "original": { ··· 99 79 "indigo": { 100 80 "flake": false, 101 81 "locked": { 102 - "lastModified": 1745333930, 103 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 82 + "lastModified": 1753693716, 83 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 104 84 "owner": "oppiliappan", 105 85 "repo": "indigo", 106 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 86 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 107 87 "type": "github" 108 88 }, 109 89 "original": { ··· 128 108 "lucide-src": { 129 109 "flake": false, 130 110 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 111 + "lastModified": 1754044466, 112 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 113 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 114 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 115 }, 136 116 "original": { 137 117 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 118 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 119 } 140 120 }, 141 121 "nixpkgs": { ··· 156 136 }, 157 137 "root": { 158 138 "inputs": { 159 - "gitignore": "gitignore", 160 139 "gomod2nix": "gomod2nix", 161 140 "htmx-src": "htmx-src", 162 141 "htmx-ws-src": "htmx-ws-src",
+101 -29
flake.nix
··· 22 22 flake = false; 23 23 }; 24 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 26 flake = false; 27 27 }; 28 28 inter-fonts-src = { ··· 37 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 38 38 flake = false; 39 39 }; 40 - gitignore = { 41 - url = "github:hercules-ci/gitignore.nix"; 42 - inputs.nixpkgs.follows = "nixpkgs"; 43 - }; 44 40 }; 45 41 46 42 outputs = { ··· 51 47 htmx-src, 52 48 htmx-ws-src, 53 49 lucide-src, 54 - gitignore, 55 50 inter-fonts-src, 56 51 sqlite-lib-src, 57 52 ibm-plex-mono-src, ··· 62 57 63 58 mkPackageSet = pkgs: 64 59 pkgs.lib.makeScope pkgs.newScope (self: { 65 - inherit (gitignore.lib) gitignoreSource; 60 + src = let 61 + fs = pkgs.lib.fileset; 62 + in 63 + fs.toSource { 64 + root = ./.; 65 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 66 + }; 66 67 buildGoApplication = 67 68 (self.callPackage "${gomod2nix}/builder" { 68 69 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 74 75 }; 75 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 78 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 80 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 92 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 93 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 94 96 in { 95 - appview = packages.appview; 96 - lexgen = packages.lexgen; 97 - knot = packages.knot; 98 - knot-unwrapped = packages.knot-unwrapped; 99 - spindle = packages.spindle; 100 - genjwks = packages.genjwks; 101 - sqlite-lib = packages.sqlite-lib; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 102 98 103 99 pkgsStatic-appview = staticPackages.appview; 104 100 pkgsStatic-knot = staticPackages.knot; ··· 131 127 pkgs.tailwindcss 132 128 pkgs.nixos-shell 133 129 pkgs.redis 130 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 134 131 packages'.lexgen 135 132 ]; 136 133 shellHook = '' 137 - mkdir -p appview/pages/static/{fonts,icons} 138 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 139 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 140 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 141 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 142 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 143 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 134 + mkdir -p appview/pages/static 135 + # no preserve is needed because watch-tailwind will want to be able to overwrite 136 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 144 137 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 145 138 ''; 146 139 env.CGO_ENABLED = 1; ··· 148 141 }); 149 142 apps = forAllSystems (system: let 150 143 pkgs = nixpkgsFor."${system}"; 144 + packages' = self.packages.${system}; 151 145 air-watcher = name: arg: 152 146 pkgs.writeShellScriptBin "run" 153 147 '' ··· 166 160 in { 167 161 watch-appview = { 168 162 type = "app"; 169 - program = ''${air-watcher "appview" ""}/bin/run''; 163 + program = toString (pkgs.writeShellScript "watch-appview" '' 164 + echo "copying static files to appview/pages/static..." 165 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 166 + ${air-watcher "appview" ""}/bin/run 167 + ''); 170 168 }; 171 169 watch-knot = { 172 170 type = "app"; ··· 176 174 type = "app"; 177 175 program = ''${tailwind-watcher}/bin/run''; 178 176 }; 179 - vm = { 177 + vm = let 178 + system = 179 + if pkgs.stdenv.hostPlatform.isAarch64 180 + then "aarch64" 181 + else "x86_64"; 182 + 183 + nixos-shell = pkgs.nixos-shell.overrideAttrs (old: { 184 + patches = 185 + (old.patches or []) 186 + ++ [ 187 + # https://github.com/Mic92/nixos-shell/pull/94 188 + (pkgs.fetchpatch { 189 + name = "fix-foreign-vm.patch"; 190 + url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch"; 191 + hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo="; 192 + }) 193 + ]; 194 + }); 195 + in { 180 196 type = "app"; 181 197 program = toString (pkgs.writeShellScript "vm" '' 182 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 198 + ${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux 183 199 ''); 184 200 }; 185 201 gomod2nix = { ··· 188 204 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 205 ''); 190 206 }; 207 + lexgen = { 208 + type = "app"; 209 + program = 210 + (pkgs.writeShellApplication { 211 + name = "lexgen"; 212 + text = '' 213 + if ! command -v lexgen > /dev/null; then 214 + echo "error: must be executed from devshell" 215 + exit 1 216 + fi 217 + 218 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 219 + cd "$rootDir" 220 + 221 + rm api/tangled/* 222 + lexgen --build-file lexicon-build-config.json lexicons 223 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 224 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 225 + go run cmd/gen.go 226 + lexgen --build-file lexicon-build-config.json lexicons 227 + rm api/tangled/*.bak 228 + ''; 229 + }) 230 + + /bin/lexgen; 231 + }; 191 232 }); 192 233 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;}; 234 + nixosModules.appview = { 235 + lib, 236 + pkgs, 237 + ... 238 + }: { 239 + imports = [./nix/modules/appview.nix]; 240 + 241 + services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 242 + }; 243 + nixosModules.knot = { 244 + lib, 245 + pkgs, 246 + ... 247 + }: { 248 + imports = [./nix/modules/knot.nix]; 249 + 250 + services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 251 + }; 252 + nixosModules.spindle = { 253 + lib, 254 + pkgs, 255 + ... 256 + }: { 257 + imports = [./nix/modules/spindle.nix]; 258 + 259 + services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 260 + }; 261 + nixosConfigurations.vm-x86_64 = import ./nix/vm.nix { 262 + inherit self nixpkgs; 263 + system = "x86_64-linux"; 264 + }; 265 + nixosConfigurations.vm-aarch64 = import ./nix/vm.nix { 266 + inherit self nixpkgs; 267 + system = "aarch64-linux"; 268 + }; 197 269 }; 198 270 }
+55 -34
go.mod
··· 1 1 module tangled.sh/tangled.sh/core 2 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 3 + go 1.24.4 6 4 7 5 require ( 8 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 9 8 github.com/alecthomas/chroma/v2 v2.15.0 9 + github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 15 16 github.com/cyphar/filepath-securejoin v0.4.1 16 17 github.com/dgraph-io/ristretto v0.2.0 17 18 github.com/docker/docker v28.2.2+incompatible ··· 22 23 github.com/go-git/go-git/v5 v5.14.0 23 24 github.com/google/uuid v1.6.0 24 25 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 26 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 27 github.com/hiddeco/sshsig v0.2.0 27 28 github.com/hpcloud/tail v1.0.0 28 29 github.com/ipfs/go-cid v0.5.0 29 30 github.com/lestrrat-go/jwx/v2 v2.1.6 30 31 github.com/mattn/go-sqlite3 v1.14.24 31 32 github.com/microcosm-cc/bluemonday v1.0.27 33 + github.com/openbao/openbao/api/v2 v2.3.0 32 34 github.com/posthog/posthog-go v1.5.5 33 - github.com/redis/go-redis/v9 v9.3.0 35 + github.com/redis/go-redis/v9 v9.7.3 34 36 github.com/resend/resend-go/v2 v2.15.0 35 37 github.com/sethvargo/go-envconfig v1.1.0 36 38 github.com/stretchr/testify v1.10.0 37 39 github.com/urfave/cli/v3 v3.3.3 38 40 github.com/whyrusleeping/cbor-gen v0.3.1 39 41 github.com/yuin/goldmark v1.4.13 40 - golang.org/x/crypto v0.38.0 41 - golang.org/x/net v0.40.0 42 + golang.org/x/crypto v0.40.0 43 + golang.org/x/net v0.42.0 44 + golang.org/x/sync v0.16.0 42 45 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 43 46 gopkg.in/yaml.v3 v3.0.1 44 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 47 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 45 48 ) 46 49 47 50 require ( 48 51 dario.cat/mergo v1.0.1 // indirect 49 52 github.com/Microsoft/go-winio v0.6.2 // indirect 50 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 53 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 54 + github.com/alecthomas/repr v0.4.0 // indirect 51 55 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 52 - github.com/avast/retry-go/v4 v4.6.1 // indirect 53 56 github.com/aymerick/douceur v0.2.0 // indirect 54 57 github.com/beorn7/perks v1.0.1 // indirect 55 58 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 59 github.com/casbin/govaluate v1.3.0 // indirect 60 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 57 61 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 62 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 59 63 github.com/containerd/errdefs v1.0.0 // indirect 60 64 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 65 github.com/containerd/log v0.1.0 // indirect ··· 68 72 github.com/docker/go-units v0.5.0 // indirect 69 73 github.com/emirpasic/gods v1.18.1 // indirect 70 74 github.com/felixge/httpsnoop v1.0.4 // indirect 75 + github.com/fsnotify/fsnotify v1.6.0 // indirect 71 76 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 77 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 78 github.com/go-git/go-billy/v5 v5.6.2 // indirect 74 - github.com/go-logr/logr v1.4.2 // indirect 79 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 80 + github.com/go-logr/logr v1.4.3 // indirect 75 81 github.com/go-logr/stdr v1.2.2 // indirect 76 82 github.com/go-redis/cache/v9 v9.0.0 // indirect 83 + github.com/go-test/deep v1.1.1 // indirect 77 84 github.com/goccy/go-json v0.10.5 // indirect 78 85 github.com/gogo/protobuf v1.3.2 // indirect 79 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 86 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 80 87 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 88 + github.com/golang/mock v1.6.0 // indirect 89 + github.com/google/go-querystring v1.1.0 // indirect 81 90 github.com/gorilla/css v1.0.1 // indirect 91 + github.com/gorilla/feeds v1.2.0 // indirect 82 92 github.com/gorilla/securecookie v1.1.2 // indirect 93 + github.com/hashicorp/errwrap v1.1.0 // indirect 83 94 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 95 + github.com/hashicorp/go-multierror v1.1.1 // indirect 96 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 97 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 98 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 99 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 85 100 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 101 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 102 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 103 + github.com/hexops/gotextdiff v1.0.3 // indirect 87 104 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 105 + github.com/ipfs/boxo v0.33.0 // indirect 106 + github.com/ipfs/go-block-format v0.2.2 // indirect 90 107 github.com/ipfs/go-datastore v0.8.2 // indirect 91 108 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 92 109 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 110 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 111 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 95 112 github.com/ipfs/go-log v1.0.5 // indirect 96 113 github.com/ipfs/go-log/v2 v2.6.0 // indirect 97 114 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 98 115 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 116 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 117 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 118 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 102 119 github.com/lestrrat-go/httpcc v1.0.1 // indirect 103 120 github.com/lestrrat-go/httprc v1.0.6 // indirect 104 121 github.com/lestrrat-go/iter v1.0.2 // indirect 105 122 github.com/lestrrat-go/option v1.0.1 // indirect 106 123 github.com/mattn/go-isatty v0.0.20 // indirect 107 124 github.com/minio/sha256-simd v1.0.1 // indirect 125 + github.com/mitchellh/mapstructure v1.5.0 // indirect 108 126 github.com/moby/docker-image-spec v1.3.1 // indirect 109 127 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 128 github.com/moby/term v0.5.2 // indirect ··· 116 134 github.com/multiformats/go-multihash v0.2.3 // indirect 117 135 github.com/multiformats/go-varint v0.0.7 // indirect 118 136 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 137 + github.com/onsi/gomega v1.37.0 // indirect 119 138 github.com/opencontainers/go-digest v1.0.0 // indirect 120 139 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 140 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 122 141 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 142 github.com/pkg/errors v0.9.1 // indirect 124 143 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 125 144 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 126 145 github.com/prometheus/client_golang v1.22.0 // indirect 127 146 github.com/prometheus/client_model v0.6.2 // indirect 128 - github.com/prometheus/common v0.63.0 // indirect 147 + github.com/prometheus/common v0.64.0 // indirect 129 148 github.com/prometheus/procfs v0.16.1 // indirect 149 + github.com/ryanuber/go-glob v1.0.0 // indirect 130 150 github.com/segmentio/asm v1.2.0 // indirect 131 151 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 152 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 136 156 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 137 157 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 138 158 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 159 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 160 + go.opentelemetry.io/otel v1.37.0 // indirect 161 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 162 + go.opentelemetry.io/otel/metric v1.37.0 // indirect 163 + go.opentelemetry.io/otel/trace v1.37.0 // indirect 143 164 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 144 165 go.uber.org/atomic v1.11.0 // indirect 145 166 go.uber.org/multierr v1.11.0 // indirect 146 167 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 168 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 169 + golang.org/x/sys v0.34.0 // indirect 170 + golang.org/x/text v0.27.0 // indirect 171 + golang.org/x/time v0.12.0 // indirect 172 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 173 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 174 + google.golang.org/grpc v1.73.0 // indirect 154 175 google.golang.org/protobuf v1.36.6 // indirect 155 176 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 177 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+131 -87
go.sum
··· 7 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 - github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 11 - github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 10 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 27 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 26 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 51 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 - github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 55 - github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 54 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 56 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 91 93 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 92 94 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 93 95 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 94 - github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 95 - github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 96 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 97 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 96 98 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 97 99 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 98 100 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 99 - github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 100 101 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 102 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 103 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 101 104 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 102 105 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 103 106 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 114 117 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 115 118 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 116 119 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 120 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 121 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 117 122 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 118 123 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 119 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 120 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 124 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 125 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 121 126 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 122 127 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 123 128 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 124 129 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 125 130 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 131 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 132 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 126 133 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 127 134 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 128 135 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 129 136 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 130 137 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 131 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 132 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 138 + github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 139 + github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 133 140 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 134 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 135 - github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 136 142 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 143 + github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 144 + github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 137 145 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 138 146 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 139 147 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 146 154 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 155 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 156 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 157 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 158 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 159 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 160 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 161 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 162 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 163 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 164 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 154 165 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 166 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 167 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 162 173 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 163 174 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 164 175 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 176 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 177 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 165 178 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 166 179 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 167 180 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 168 181 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= 182 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 183 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 171 184 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 172 185 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 186 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 187 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 188 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 173 189 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 174 190 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 175 191 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 176 192 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= 193 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 194 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 195 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 196 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 197 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 198 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 199 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 200 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 201 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 202 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 179 203 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 180 204 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 181 205 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 182 206 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 207 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 208 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 183 209 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 184 210 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 185 211 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 189 215 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 190 216 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 191 217 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= 218 + github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 219 + github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 220 + github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 221 + github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 196 222 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 197 223 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 198 224 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 205 231 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 206 232 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 207 233 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= 234 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 235 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 236 + github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 237 + github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 212 238 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 213 239 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 214 240 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 216 242 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 217 243 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 218 244 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 245 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 222 246 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 223 247 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 229 253 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 230 254 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 231 255 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= 256 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 257 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 234 258 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 235 259 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 236 260 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 239 263 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 240 264 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 241 265 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= 266 + github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 267 + github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 244 268 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 245 269 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 246 270 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 251 275 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 252 276 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 253 277 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= 278 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 279 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 260 280 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 261 281 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 262 282 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 265 285 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 286 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 267 287 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 288 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 289 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 268 290 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 269 291 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 270 292 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 281 303 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 282 304 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 283 305 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 306 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 287 307 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 308 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 309 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 310 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 318 336 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 337 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 338 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= 339 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 340 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 341 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 342 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 323 343 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 324 344 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 325 345 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 326 346 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 347 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 348 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 349 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 329 350 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 330 351 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 331 352 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 346 367 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 347 368 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 348 369 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= 370 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 371 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 351 372 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 352 373 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 353 374 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= 375 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 376 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 356 377 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 357 378 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 358 379 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 360 381 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 361 382 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 362 383 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 384 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 385 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 363 386 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 364 387 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 365 388 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 404 427 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 405 428 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 406 429 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 430 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 407 431 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 408 432 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 409 433 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 413 437 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 414 438 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 415 439 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= 440 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= 441 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= 442 + go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 443 + go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 444 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 445 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 422 446 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 423 447 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= 448 + go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 449 + go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 450 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 451 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 452 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 453 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 454 + go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 455 + go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 432 456 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 433 457 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 434 458 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 451 475 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 452 476 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 453 477 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= 478 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 479 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 480 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 481 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 482 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 458 483 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 459 484 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 460 485 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 461 486 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 487 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 462 488 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 463 489 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 464 490 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 465 491 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 492 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 466 493 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 467 494 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 468 495 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 471 498 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 472 499 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 473 500 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 501 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 474 502 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 475 503 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 476 504 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 480 508 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 481 509 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 482 510 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= 511 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 512 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 513 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 514 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 485 515 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 486 516 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 517 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 489 519 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 520 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 521 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= 522 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 523 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 494 524 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 495 525 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 496 526 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 502 532 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 533 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 534 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 535 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 505 536 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 537 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 506 538 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 539 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 540 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 510 542 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 543 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 544 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 545 + golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 546 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 547 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 548 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 549 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 550 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 517 551 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= 552 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 553 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 554 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 555 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 520 556 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 521 557 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 522 558 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 523 559 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 524 560 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 525 561 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= 562 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 563 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 564 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 565 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 566 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 528 567 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 529 568 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 530 569 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 532 571 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 533 572 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 534 573 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= 574 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 575 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 576 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 577 + golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 578 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 579 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 580 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 539 581 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 582 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 583 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 547 589 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 548 590 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 549 591 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 592 + golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 550 593 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 551 594 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 552 595 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 553 596 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 597 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 554 598 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 599 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 556 600 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 557 601 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 558 602 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 559 603 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= 604 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= 605 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= 606 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= 607 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 608 + google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 609 + google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 566 610 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 567 611 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 568 612 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 599 643 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 600 644 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 601 645 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= 646 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 647 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 604 648 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 605 649 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20 -4
guard/guard.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 6 8 "log/slog" 7 9 "net/http" 8 10 "net/url" ··· 13 15 "github.com/bluesky-social/indigo/atproto/identity" 14 16 securejoin "github.com/cyphar/filepath-securejoin" 15 17 "github.com/urfave/cli/v3" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 + "tangled.sh/tangled.sh/core/idresolver" 17 19 "tangled.sh/tangled.sh/core/log" 18 20 ) 19 21 ··· 43 45 Usage: "internal API endpoint", 44 46 Value: "http://localhost:5444", 45 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 46 53 }, 47 54 } 48 55 } ··· 54 61 gitDir := cmd.String("git-dir") 55 62 logPath := cmd.String("log-path") 56 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 57 65 58 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 67 if err != nil { ··· 149 157 "fullPath", fullPath, 150 158 "client", clientIP) 151 159 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 154 166 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 156 171 } 172 + io.Copy(os.Stderr, motdReader) 157 173 158 174 gitCmd := exec.Command(gitCommand, fullPath) 159 175 gitCmd.Stdout = os.Stdout
+24
hook/hook.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 "net/http" 8 9 "os" ··· 10 11 11 12 "github.com/urfave/cli/v3" 12 13 ) 14 + 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 13 18 14 19 // The hook command is nested like so: 15 20 // ··· 36 41 Usage: "endpoint for the internal API", 37 42 Value: "http://localhost:5444", 38 43 }, 44 + &cli.StringSliceFlag{ 45 + Name: "push-option", 46 + Usage: "any push option from git", 47 + }, 39 48 }, 40 49 Commands: []*cli.Command{ 41 50 { ··· 52 61 userDid := cmd.String("user-did") 53 62 userHandle := cmd.String("user-handle") 54 63 endpoint := cmd.String("internal-api") 64 + pushOptions := cmd.StringSlice("push-option") 55 65 56 66 payloadReader := bufio.NewReader(os.Stdin) 57 67 payload, _ := payloadReader.ReadString('\n') ··· 67 77 req.Header.Set("X-Git-Dir", gitDir) 68 78 req.Header.Set("X-Git-User-Did", userDid) 69 79 req.Header.Set("X-Git-User-Handle", userHandle) 80 + if pushOptions != nil { 81 + for _, option := range pushOptions { 82 + req.Header.Add("X-Git-Push-Option", option) 83 + } 84 + } 70 85 71 86 resp, err := client.Do(req) 72 87 if err != nil { ··· 76 91 77 92 if resp.StatusCode != http.StatusOK { 78 93 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 94 + } 95 + 96 + var data HookResponse 97 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 98 + return fmt.Errorf("failed to decode response: %w", err) 99 + } 100 + 101 + for _, message := range data.Messages { 102 + fmt.Println(message) 79 103 } 80 104 81 105 return nil
+6 -1
hook/setup.go
··· 133 133 134 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 136 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve 136 + push_options=() 137 + for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do 138 + option_var="GIT_PUSH_OPTION_$i" 139 + push_options+=(-push-option "${!option_var}") 140 + done 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 137 142 `, executablePath, config.internalApi) 138 143 139 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+116
idresolver/resolver.go
··· 1 + package idresolver 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/carlmjohnson/versioninfo" 14 + ) 15 + 16 + type Resolver struct { 17 + directory identity.Directory 18 + } 19 + 20 + func BaseDirectory() identity.Directory { 21 + base := identity.BaseDirectory{ 22 + PLCURL: identity.DefaultPLCURL, 23 + HTTPClient: http.Client{ 24 + Timeout: time.Second * 10, 25 + Transport: &http.Transport{ 26 + // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 27 + IdleConnTimeout: time.Millisecond * 1000, 28 + MaxIdleConns: 100, 29 + }, 30 + }, 31 + Resolver: net.Resolver{ 32 + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 33 + d := net.Dialer{Timeout: time.Second * 3} 34 + return d.DialContext(ctx, network, address) 35 + }, 36 + }, 37 + TryAuthoritativeDNS: true, 38 + // primary Bluesky PDS instance only supports HTTP resolution method 39 + SkipDNSDomainSuffixes: []string{".bsky.social"}, 40 + UserAgent: "indigo-identity/" + versioninfo.Short(), 41 + } 42 + return &base 43 + } 44 + 45 + func RedisDirectory(url string) (identity.Directory, error) { 46 + hitTTL := time.Hour * 24 47 + errTTL := time.Second * 30 48 + invalidHandleTTL := time.Minute * 5 49 + return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 50 + } 51 + 52 + func DefaultResolver() *Resolver { 53 + return &Resolver{ 54 + directory: identity.DefaultDirectory(), 55 + } 56 + } 57 + 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return &Resolver{ 64 + directory: directory, 65 + }, nil 66 + } 67 + 68 + func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 69 + id, err := syntax.ParseAtIdentifier(arg) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + return r.directory.Lookup(ctx, *id) 75 + } 76 + 77 + func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 78 + results := make([]*identity.Identity, len(idents)) 79 + var wg sync.WaitGroup 80 + 81 + done := make(chan struct{}) 82 + defer close(done) 83 + 84 + for idx, ident := range idents { 85 + wg.Add(1) 86 + go func(index int, id string) { 87 + defer wg.Done() 88 + 89 + select { 90 + case <-ctx.Done(): 91 + results[index] = nil 92 + case <-done: 93 + results[index] = nil 94 + default: 95 + identity, _ := r.ResolveIdent(ctx, id) 96 + results[index] = identity 97 + } 98 + }(idx, ident) 99 + } 100 + 101 + wg.Wait() 102 + return results 103 + } 104 + 105 + func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 106 + id, err := syntax.ParseAtIdentifier(arg) 107 + if err != nil { 108 + return err 109 + } 110 + 111 + return r.directory.Purge(ctx, *id) 112 + } 113 + 114 + func (r *Resolver) Directory() identity.Directory { 115 + return r.directory 116 + }
+13 -2
input.css
··· 70 70 details summary::-webkit-details-marker { 71 71 display: none; 72 72 } 73 + 74 + code { 75 + @apply px-1 font-mono rounded bg-gray-100 dark:bg-gray-700; 76 + } 73 77 } 74 78 75 79 @layer components { ··· 100 104 101 105 .prose img { 102 106 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 107 + margin: 0; 105 108 vertical-align: middle; 109 + } 110 + 111 + .prose input { 112 + @apply inline-block my-0 mb-1 mx-1; 113 + } 114 + 115 + .prose input[type="checkbox"] { 116 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 106 117 } 107 118 } 108 119 @layer utilities {
+13
jetstream/jetstream.go
··· 52 52 j.mu.Unlock() 53 53 } 54 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 55 68 type processor func(context.Context, *models.Event) error 56 69 57 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 ··· 23 25 24 26 // This disables signature verification so use with caution. 25 27 Dev bool `env:"DEV, default=false"` 28 + } 29 + 30 + func (s Server) Did() syntax.DID { 31 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 26 32 } 27 33 28 34 type Config struct {
-8
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 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 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+19 -12
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+37 -18
knotserver/handler.go
··· 8 8 "runtime/debug" 9 9 10 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/idresolver" 11 12 "tangled.sh/tangled.sh/core/jetstream" 12 13 "tangled.sh/tangled.sh/core/knotserver/config" 13 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 14 17 "tangled.sh/tangled.sh/core/notifier" 15 18 "tangled.sh/tangled.sh/core/rbac" 16 - ) 17 - 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 19 ) 21 20 22 21 type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 29 30 30 // init is a channel that is closed when the knot has been initailized 31 31 // i.e. when the first user (knot owner) has been added. ··· 37 37 r := chi.NewRouter() 38 38 39 39 h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - init: make(chan struct{}), 40 + c: c, 41 + db: db, 42 + e: e, 43 + l: l, 44 + jc: jc, 45 + n: n, 46 + resolver: idresolver.DefaultResolver(), 47 + init: make(chan struct{}), 47 48 } 48 49 49 - err := e.AddKnot(ThisServer) 50 + err := e.AddKnot(rbac.ThisServer) 50 51 if err != nil { 51 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 52 53 } ··· 131 132 }) 132 133 }) 133 134 135 + // xrpc apis 136 + r.Mount("/xrpc", h.XrpcRouter()) 137 + 134 138 // Create a new repository. 135 139 r.Route("/repo", func(r chi.Router) { 136 140 r.Use(h.VerifySignature) ··· 161 165 r.Get("/keys", h.Keys) 162 166 163 167 return r, nil 168 + } 169 + 170 + func (h *Handle) XrpcRouter() http.Handler { 171 + logger := tlog.New("knots") 172 + 173 + xrpc := &xrpc.Xrpc{ 174 + Config: h.c, 175 + Db: h.db, 176 + Ingester: h.jc, 177 + Enforcer: h.e, 178 + Logger: logger, 179 + Notifier: h.n, 180 + Resolver: h.resolver, 181 + } 182 + return xrpc.Router() 164 183 } 165 184 166 185 // version is set during build time.
+65 -4
knotserver/ingester.go
··· 17 17 "github.com/bluesky-social/jetstream/pkg/models" 18 18 securejoin "github.com/cyphar/filepath-securejoin" 19 19 "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/knotserver/db" 22 22 "tangled.sh/tangled.sh/core/knotserver/git" 23 23 "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/rbac" 24 25 "tangled.sh/tangled.sh/core/workflow" 25 26 ) 26 27 ··· 46 47 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 47 48 } 48 49 49 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 50 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 50 51 if err != nil || !ok { 51 52 l.Error("failed to add member", "did", did) 52 53 return fmt.Errorf("failed to enforce permissions: %w", err) 53 54 } 54 55 55 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 56 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 56 57 l.Error("failed to add member", "error", err) 57 58 return fmt.Errorf("failed to add member: %w", err) 58 59 } ··· 212 213 return h.db.InsertEvent(event, h.n) 213 214 } 214 215 216 + // duplicated from add collaborator 217 + func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 218 + repoAt, err := syntax.ParseATURI(record.Repo) 219 + if err != nil { 220 + return err 221 + } 222 + 223 + resolver := idresolver.DefaultResolver() 224 + 225 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + if err != nil || subjectId.Handle.IsInvalidHandle() { 227 + return err 228 + } 229 + 230 + // TODO: fix this for good, we need to fetch the record here unfortunately 231 + // resolve this aturi to extract the repo record 232 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 233 + if err != nil || owner.Handle.IsInvalidHandle() { 234 + return fmt.Errorf("failed to resolve handle: %w", err) 235 + } 236 + 237 + xrpcc := xrpc.Client{ 238 + Host: owner.PDSEndpoint(), 239 + } 240 + 241 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 242 + if err != nil { 243 + return err 244 + } 245 + 246 + repo := resp.Value.Val.(*tangled.Repo) 247 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 + 249 + // check perms for this user 250 + if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 251 + return fmt.Errorf("insufficient permissions: %w", err) 252 + } 253 + 254 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 255 + return err 256 + } 257 + h.jc.AddDid(subjectId.DID.String()) 258 + 259 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 260 + return err 261 + } 262 + 263 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 264 + } 265 + 215 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 216 267 l := log.FromContext(ctx) 217 268 ··· 265 316 defer func() { 266 317 eventTime := event.TimeUS 267 318 lastTimeUs := eventTime + 1 268 - fmt.Println("lastTimeUs", lastTimeUs) 269 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 270 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 271 321 } ··· 291 341 if err := h.processKnotMember(ctx, did, record); err != nil { 292 342 return fmt.Errorf("failed to process knot member: %w", err) 293 343 } 344 + 294 345 case tangled.RepoPullNSID: 295 346 var record tangled.RepoPull 296 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 299 350 if err := h.processPull(ctx, did, record); err != nil { 300 351 return fmt.Errorf("failed to process knot member: %w", err) 301 352 } 353 + 354 + case tangled.RepoCollaboratorNSID: 355 + var record tangled.RepoCollaborator 356 + if err := json.Unmarshal(raw, &record); err != nil { 357 + return fmt.Errorf("failed to unmarshal record: %w", err) 358 + } 359 + if err := h.processCollaborator(ctx, did, record); err != nil { 360 + return fmt.Errorf("failed to process knot member: %w", err) 361 + } 362 + 302 363 } 303 364 304 365 return err
+67 -7
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 13 14 "github.com/go-chi/chi/v5" 14 15 "github.com/go-chi/chi/v5/middleware" 15 16 "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 16 18 "tangled.sh/tangled.sh/core/knotserver/config" 17 19 "tangled.sh/tangled.sh/core/knotserver/db" 18 20 "tangled.sh/tangled.sh/core/knotserver/git" ··· 38 40 return 39 41 } 40 42 41 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 43 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 42 44 if err != nil || !ok { 43 45 w.WriteHeader(http.StatusForbidden) 44 46 return ··· 64 66 return 65 67 } 66 68 69 + type PushOptions struct { 70 + skipCi bool 71 + verboseCi bool 72 + } 73 + 67 74 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 68 75 l := h.l.With("handler", "PostReceiveHook") 69 76 ··· 90 97 // non-fatal 91 98 } 92 99 100 + // extract any push options 101 + pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 102 + pushOptions := PushOptions{} 103 + for _, option := range pushOptionsRaw { 104 + if option == "skip-ci" || option == "ci-skip" { 105 + pushOptions.skipCi = true 106 + } 107 + if option == "verbose-ci" || option == "ci-verbose" { 108 + pushOptions.verboseCi = true 109 + } 110 + } 111 + 112 + resp := hook.HookResponse{ 113 + Messages: make([]string, 0), 114 + } 115 + 93 116 for _, line := range lines { 94 117 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 95 118 if err != nil { ··· 97 120 // non-fatal 98 121 } 99 122 100 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 123 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 101 124 if err != nil { 102 125 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 103 126 // non-fatal 104 127 } 105 128 } 129 + 130 + writeJSON(w, resp) 106 131 } 107 132 108 133 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 121 146 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 122 147 } 123 148 124 - meta := gr.RefUpdateMeta(line) 149 + var errs error 150 + meta, err := gr.RefUpdateMeta(line) 151 + errors.Join(errs, err) 125 152 126 153 metaRecord := meta.AsRecord() 127 154 ··· 145 172 EventJson: string(eventJson), 146 173 } 147 174 148 - return h.db.InsertEvent(event, h.n) 175 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 149 176 } 150 177 151 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 178 + func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 179 + if pushOptions.skipCi { 180 + return nil 181 + } 182 + 152 183 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 153 184 if err != nil { 154 185 return err ··· 169 200 return err 170 201 } 171 202 203 + pipelineParseErrors := []string{} 204 + 172 205 var pipeline workflow.Pipeline 173 206 for _, e := range workflowDir { 174 207 if !e.IsFile { ··· 183 216 184 217 wf, err := workflow.FromFile(e.Name, contents) 185 218 if err != nil { 186 - // TODO: log here, respond to client that is pushing 187 219 h.l.Error("failed to parse workflow", "err", err, "path", fpath) 220 + pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 188 221 continue 189 222 } 190 223 ··· 209 242 }, 210 243 } 211 244 212 - // TODO: send the diagnostics back to the user here via stderr 213 245 cp := compiler.Compile(pipeline) 214 246 eventJson, err := json.Marshal(cp) 215 247 if err != nil { 216 248 return err 249 + } 250 + 251 + if pushOptions.verboseCi { 252 + hasDiagnostics := false 253 + if len(pipelineParseErrors) > 0 { 254 + hasDiagnostics = true 255 + *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 256 + for _, error := range pipelineParseErrors { 257 + *clientMsgs = append(*clientMsgs, error) 258 + } 259 + } 260 + if len(compiler.Diagnostics.Errors) > 0 { 261 + hasDiagnostics = true 262 + *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 263 + for _, error := range compiler.Diagnostics.Errors { 264 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 265 + } 266 + } 267 + if len(compiler.Diagnostics.Warnings) > 0 { 268 + hasDiagnostics = true 269 + *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 270 + for _, warning := range compiler.Diagnostics.Warnings { 271 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 272 + } 273 + } 274 + if !hasDiagnostics { 275 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 276 + } 217 277 } 218 278 219 279 // do not run empty pipelines
+28 -11
knotserver/routes.go
··· 29 29 "tangled.sh/tangled.sh/core/knotserver/db" 30 30 "tangled.sh/tangled.sh/core/knotserver/git" 31 31 "tangled.sh/tangled.sh/core/patchutil" 32 + "tangled.sh/tangled.sh/core/rbac" 32 33 "tangled.sh/tangled.sh/core/types" 33 34 ) 34 35 ··· 285 286 mimeType = "image/svg+xml" 286 287 } 287 288 288 - if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 289 - l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 290 - writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 289 + // allow image, video, and text/plain files to be served directly 290 + switch { 291 + case strings.HasPrefix(mimeType, "image/"): 292 + // allowed 293 + case strings.HasPrefix(mimeType, "video/"): 294 + // allowed 295 + case strings.HasPrefix(mimeType, "text/plain"): 296 + // allowed 297 + default: 298 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 291 300 return 292 301 } 293 302 ··· 352 361 353 362 ref := strings.TrimSuffix(file, ".tar.gz") 354 363 364 + unescapedRef, err := url.PathUnescape(ref) 365 + if err != nil { 366 + notFound(w) 367 + return 368 + } 369 + 370 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 371 + 355 372 // This allows the browser to use a proper name for the file when 356 373 // downloading 357 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 374 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 358 375 setContentDisposition(w, filename) 359 376 setGZipMIME(w) 360 377 361 378 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 362 - gr, err := git.Open(path, ref) 379 + gr, err := git.Open(path, unescapedRef) 363 380 if err != nil { 364 381 notFound(w) 365 382 return ··· 368 385 gw := gzip.NewWriter(w) 369 386 defer gw.Close() 370 387 371 - prefix := fmt.Sprintf("%s-%s", name, ref) 388 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 372 389 err = gr.WriteTar(gw, prefix) 373 390 if err != nil { 374 391 // once we start writing to the body we can't report error anymore ··· 674 691 } 675 692 676 693 // add perms for this user to access the repo 677 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 694 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 678 695 if err != nil { 679 696 l.Error("adding repo permissions", "error", err.Error()) 680 697 writeError(w, err.Error(), http.StatusInternalServerError) ··· 892 909 } 893 910 894 911 // add perms for this user to access the repo 895 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 912 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 896 913 if err != nil { 897 914 l.Error("adding repo permissions", "error", err.Error()) 898 915 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1146 1163 } 1147 1164 h.jc.AddDid(did) 1148 1165 1149 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1166 + if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1150 1167 l.Error("adding member", "error", err.Error()) 1151 1168 writeError(w, err.Error(), http.StatusInternalServerError) 1152 1169 return ··· 1184 1201 h.jc.AddDid(data.Did) 1185 1202 1186 1203 repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1187 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1204 + if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1188 1205 l.Error("adding repo collaborator", "error", err.Error()) 1189 1206 writeError(w, err.Error(), http.StatusInternalServerError) 1190 1207 return ··· 1281 1298 } 1282 1299 h.jc.AddDid(data.Did) 1283 1300 1284 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1301 + if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1285 1302 l.Error("adding owner", "error", err.Error()) 1286 1303 writeError(w, err.Error(), http.StatusInternalServerError) 1287 1304 return
+1
knotserver/server.go
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
-5
knotserver/util.go
··· 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "github.com/go-chi/chi/v5" 11 - "github.com/microcosm-cc/bluemonday" 12 11 ) 13 - 14 - func sanitize(content []byte) []byte { 15 - return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 16 - } 17 12 18 13 func didPath(r *http.Request) string { 19 14 did := chi.URLParam(r, "did")
+149
knotserver/xrpc/router.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "strings" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/jetstream" 14 + "tangled.sh/tangled.sh/core/knotserver/config" 15 + "tangled.sh/tangled.sh/core/knotserver/db" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + 19 + "github.com/bluesky-social/indigo/atproto/auth" 20 + "github.com/go-chi/chi/v5" 21 + ) 22 + 23 + type Xrpc struct { 24 + Config *config.Config 25 + Db *db.DB 26 + Ingester *jetstream.JetstreamClient 27 + Enforcer *rbac.Enforcer 28 + Logger *slog.Logger 29 + Notifier *notifier.Notifier 30 + Resolver *idresolver.Resolver 31 + } 32 + 33 + func (x *Xrpc) Router() http.Handler { 34 + r := chi.NewRouter() 35 + 36 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 + 38 + return r 39 + } 40 + 41 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 + l := x.Logger.With("url", r.URL) 44 + 45 + token := r.Header.Get("Authorization") 46 + token = strings.TrimPrefix(token, "Bearer ") 47 + 48 + s := auth.ServiceAuthValidator{ 49 + Audience: x.Config.Server.Did().String(), 50 + Dir: x.Resolver.Directory(), 51 + } 52 + 53 + did, err := s.Validate(r.Context(), token, nil) 54 + if err != nil { 55 + l.Error("signature verification failed", "err", err) 56 + writeError(w, AuthError(err), http.StatusForbidden) 57 + return 58 + } 59 + 60 + r = r.WithContext( 61 + context.WithValue(r.Context(), ActorDid, did), 62 + ) 63 + 64 + next.ServeHTTP(w, r) 65 + }) 66 + } 67 + 68 + type XrpcError struct { 69 + Tag string `json:"error"` 70 + Message string `json:"message"` 71 + } 72 + 73 + func NewXrpcError(opts ...ErrOpt) XrpcError { 74 + x := XrpcError{} 75 + for _, o := range opts { 76 + o(&x) 77 + } 78 + 79 + return x 80 + } 81 + 82 + type ErrOpt = func(xerr *XrpcError) 83 + 84 + func WithTag(tag string) ErrOpt { 85 + return func(xerr *XrpcError) { 86 + xerr.Tag = tag 87 + } 88 + } 89 + 90 + func WithMessage[S ~string](s S) ErrOpt { 91 + return func(xerr *XrpcError) { 92 + xerr.Message = string(s) 93 + } 94 + } 95 + 96 + func WithError(e error) ErrOpt { 97 + return func(xerr *XrpcError) { 98 + xerr.Message = e.Error() 99 + } 100 + } 101 + 102 + var MissingActorDidError = NewXrpcError( 103 + WithTag("MissingActorDid"), 104 + WithMessage("actor DID not supplied"), 105 + ) 106 + 107 + var AuthError = func(err error) XrpcError { 108 + return NewXrpcError( 109 + WithTag("Auth"), 110 + WithError(fmt.Errorf("signature verification failed: %w", err)), 111 + ) 112 + } 113 + 114 + var InvalidRepoError = func(r string) XrpcError { 115 + return NewXrpcError( 116 + WithTag("InvalidRepo"), 117 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 + ) 119 + } 120 + 121 + var AccessControlError = func(d string) XrpcError { 122 + return NewXrpcError( 123 + WithTag("AccessControl"), 124 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 + ) 126 + } 127 + 128 + var GitError = func(e error) XrpcError { 129 + return NewXrpcError( 130 + WithTag("Git"), 131 + WithError(fmt.Errorf("git error: %w", e)), 132 + ) 133 + } 134 + 135 + func GenericError(err error) XrpcError { 136 + return NewXrpcError( 137 + WithTag("Generic"), 138 + WithError(err), 139 + ) 140 + } 141 + 142 + // this is slightly different from http_util::write_error to follow the spec: 143 + // 144 + // the json object returned must include an "error" and a "message" 145 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 + w.Header().Set("Content-Type", "application/json") 147 + w.WriteHeader(status) 148 + json.NewEncoder(w).Encode(e) 149 + }
+87
knotserver/xrpc/set_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + ) 16 + 17 + const ActorDid string = "ActorDid" 18 + 19 + func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoSetDefaultBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 + if err != nil { 62 + fail(GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(InvalidRepoError(data.Repo)) 76 + return 77 + } 78 + 79 + err = gr.SetDefaultBranch(data.DefaultBranch) 80 + if err != nil { 81 + l.Error("setting default branch", "error", err.Error()) 82 + writeError(w, GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
+263
lexicons/pipeline/pipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "dependencies", 153 + "steps", 154 + "environment", 155 + "clone" 156 + ], 157 + "properties": { 158 + "name": { 159 + "type": "string" 160 + }, 161 + "dependencies": { 162 + "type": "array", 163 + "items": { 164 + "type": "ref", 165 + "ref": "#dependency" 166 + } 167 + }, 168 + "steps": { 169 + "type": "array", 170 + "items": { 171 + "type": "ref", 172 + "ref": "#step" 173 + } 174 + }, 175 + "environment": { 176 + "type": "array", 177 + "items": { 178 + "type": "ref", 179 + "ref": "#pair" 180 + } 181 + }, 182 + "clone": { 183 + "type": "ref", 184 + "ref": "#cloneOpts" 185 + } 186 + } 187 + }, 188 + "dependency": { 189 + "type": "object", 190 + "required": [ 191 + "registry", 192 + "packages" 193 + ], 194 + "properties": { 195 + "registry": { 196 + "type": "string" 197 + }, 198 + "packages": { 199 + "type": "array", 200 + "items": { 201 + "type": "string" 202 + } 203 + } 204 + } 205 + }, 206 + "cloneOpts": { 207 + "type": "object", 208 + "required": [ 209 + "skip", 210 + "depth", 211 + "submodules" 212 + ], 213 + "properties": { 214 + "skip": { 215 + "type": "boolean" 216 + }, 217 + "depth": { 218 + "type": "integer" 219 + }, 220 + "submodules": { 221 + "type": "boolean" 222 + } 223 + } 224 + }, 225 + "step": { 226 + "type": "object", 227 + "required": [ 228 + "name", 229 + "command" 230 + ], 231 + "properties": { 232 + "name": { 233 + "type": "string" 234 + }, 235 + "command": { 236 + "type": "string" 237 + }, 238 + "environment": { 239 + "type": "array", 240 + "items": { 241 + "type": "ref", 242 + "ref": "#pair" 243 + } 244 + } 245 + } 246 + }, 247 + "pair": { 248 + "type": "object", 249 + "required": [ 250 + "key", 251 + "value" 252 + ], 253 + "properties": { 254 + "key": { 255 + "type": "string" 256 + }, 257 + "value": { 258 + "type": "string" 259 + } 260 + } 261 + } 262 + } 263 + }
-263
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "triggerMetadata", 14 - "workflows" 15 - ], 16 - "properties": { 17 - "triggerMetadata": { 18 - "type": "ref", 19 - "ref": "#triggerMetadata" 20 - }, 21 - "workflows": { 22 - "type": "array", 23 - "items": { 24 - "type": "ref", 25 - "ref": "#workflow" 26 - } 27 - } 28 - } 29 - } 30 - }, 31 - "triggerMetadata": { 32 - "type": "object", 33 - "required": [ 34 - "kind", 35 - "repo" 36 - ], 37 - "properties": { 38 - "kind": { 39 - "type": "string", 40 - "enum": [ 41 - "push", 42 - "pull_request", 43 - "manual" 44 - ] 45 - }, 46 - "repo": { 47 - "type": "ref", 48 - "ref": "#triggerRepo" 49 - }, 50 - "push": { 51 - "type": "ref", 52 - "ref": "#pushTriggerData" 53 - }, 54 - "pullRequest": { 55 - "type": "ref", 56 - "ref": "#pullRequestTriggerData" 57 - }, 58 - "manual": { 59 - "type": "ref", 60 - "ref": "#manualTriggerData" 61 - } 62 - } 63 - }, 64 - "triggerRepo": { 65 - "type": "object", 66 - "required": [ 67 - "knot", 68 - "did", 69 - "repo", 70 - "defaultBranch" 71 - ], 72 - "properties": { 73 - "knot": { 74 - "type": "string" 75 - }, 76 - "did": { 77 - "type": "string", 78 - "format": "did" 79 - }, 80 - "repo": { 81 - "type": "string" 82 - }, 83 - "defaultBranch": { 84 - "type": "string" 85 - } 86 - } 87 - }, 88 - "pushTriggerData": { 89 - "type": "object", 90 - "required": [ 91 - "ref", 92 - "newSha", 93 - "oldSha" 94 - ], 95 - "properties": { 96 - "ref": { 97 - "type": "string" 98 - }, 99 - "newSha": { 100 - "type": "string", 101 - "minLength": 40, 102 - "maxLength": 40 103 - }, 104 - "oldSha": { 105 - "type": "string", 106 - "minLength": 40, 107 - "maxLength": 40 108 - } 109 - } 110 - }, 111 - "pullRequestTriggerData": { 112 - "type": "object", 113 - "required": [ 114 - "sourceBranch", 115 - "targetBranch", 116 - "sourceSha", 117 - "action" 118 - ], 119 - "properties": { 120 - "sourceBranch": { 121 - "type": "string" 122 - }, 123 - "targetBranch": { 124 - "type": "string" 125 - }, 126 - "sourceSha": { 127 - "type": "string", 128 - "minLength": 40, 129 - "maxLength": 40 130 - }, 131 - "action": { 132 - "type": "string" 133 - } 134 - } 135 - }, 136 - "manualTriggerData": { 137 - "type": "object", 138 - "properties": { 139 - "inputs": { 140 - "type": "array", 141 - "items": { 142 - "type": "ref", 143 - "ref": "#pair" 144 - } 145 - } 146 - } 147 - }, 148 - "workflow": { 149 - "type": "object", 150 - "required": [ 151 - "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 156 - ], 157 - "properties": { 158 - "name": { 159 - "type": "string" 160 - }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 181 - }, 182 - "clone": { 183 - "type": "ref", 184 - "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 196 - "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 - } 204 - } 205 - }, 206 - "cloneOpts": { 207 - "type": "object", 208 - "required": [ 209 - "skip", 210 - "depth", 211 - "submodules" 212 - ], 213 - "properties": { 214 - "skip": { 215 - "type": "boolean" 216 - }, 217 - "depth": { 218 - "type": "integer" 219 - }, 220 - "submodules": { 221 - "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 - } 245 - } 246 - }, 247 - "pair": { 248 - "type": "object", 249 - "required": [ 250 - "key", 251 - "value" 252 - ], 253 - "properties": { 254 - "key": { 255 - "type": "string" 256 - }, 257 - "value": { 258 - "type": "string" 259 - } 260 - } 261 - } 262 - } 263 - }
+37
lexicons/repo/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/repo/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+29
lexicons/repo/defaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+67
lexicons/repo/listSecrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+31
lexicons/repo/removeSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+54
lexicons/repo/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "format": "datetime", 38 + "minGraphemes": 1, 39 + "maxGraphemes": 140 40 + }, 41 + "source": { 42 + "type": "string", 43 + "format": "uri", 44 + "description": "source of the repo" 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
+25
lexicons/spindle/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - } 25 -
+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 + }
+128 -59
nix/gomod2nix.toml
··· 11 11 version = "v0.6.2" 12 12 hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 13 [mod."github.com/ProtonMail/go-crypto"] 14 - version = "v1.2.0" 15 - hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo=" 14 + version = "v1.3.0" 15 + hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/alecthomas/assert/v2"] 17 + version = "v2.11.0" 18 + hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" 16 19 [mod."github.com/alecthomas/chroma/v2"] 17 20 version = "v2.19.0" 18 21 hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 19 22 replaced = "github.com/oppiliappan/chroma/v2" 23 + [mod."github.com/alecthomas/repr"] 24 + version = "v0.4.0" 25 + hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU=" 20 26 [mod."github.com/anmitsu/go-shlex"] 21 27 version = "v0.0.0-20200514113438-38f4b401e2be" 22 28 hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" ··· 34 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 35 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 36 42 [mod."github.com/bluesky-social/indigo"] 37 - version = "v0.0.0-20250520232546-236dd575c91e" 38 - hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0=" 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 39 45 [mod."github.com/bluesky-social/jetstream"] 40 46 version = "v0.0.0-20241210005130-ea96859b93d1" 41 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 51 57 [mod."github.com/casbin/govaluate"] 52 58 version = "v1.3.0" 53 59 hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 60 + [mod."github.com/cenkalti/backoff/v4"] 61 + version = "v4.3.0" 62 + hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8=" 54 63 [mod."github.com/cespare/xxhash/v2"] 55 64 version = "v2.3.0" 56 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 57 66 [mod."github.com/cloudflare/circl"] 58 - version = "v1.6.0" 59 - hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0=" 67 + version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 + hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 60 72 [mod."github.com/containerd/errdefs"] 61 73 version = "v1.0.0" 62 74 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 105 117 [mod."github.com/felixge/httpsnoop"] 106 118 version = "v1.0.4" 107 119 hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 120 + [mod."github.com/fsnotify/fsnotify"] 121 + version = "v1.6.0" 122 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 108 123 [mod."github.com/gliderlabs/ssh"] 109 124 version = "v0.3.8" 110 125 hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" ··· 127 142 version = "v5.17.0" 128 143 hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 129 144 replaced = "github.com/oppiliappan/go-git/v5" 145 + [mod."github.com/go-jose/go-jose/v3"] 146 + version = "v3.0.4" 147 + hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 130 148 [mod."github.com/go-logr/logr"] 131 - version = "v1.4.2" 132 - hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI=" 149 + version = "v1.4.3" 150 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 133 151 [mod."github.com/go-logr/stdr"] 134 152 version = "v1.2.2" 135 153 hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 136 154 [mod."github.com/go-redis/cache/v9"] 137 155 version = "v9.0.0" 138 156 hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 157 + [mod."github.com/go-test/deep"] 158 + version = "v1.1.1" 159 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 139 160 [mod."github.com/goccy/go-json"] 140 161 version = "v0.10.5" 141 162 hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" ··· 143 164 version = "v1.3.2" 144 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 145 166 [mod."github.com/golang-jwt/jwt/v5"] 146 - version = "v5.2.2" 147 - hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4=" 167 + version = "v5.2.3" 168 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 148 169 [mod."github.com/golang/groupcache"] 149 170 version = "v0.0.0-20241129210726-2c02b8208cf8" 150 171 hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 172 + [mod."github.com/golang/mock"] 173 + version = "v1.6.0" 174 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 151 178 [mod."github.com/google/uuid"] 152 179 version = "v1.6.0" 153 180 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 154 181 [mod."github.com/gorilla/css"] 155 182 version = "v1.0.1" 156 183 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 157 187 [mod."github.com/gorilla/securecookie"] 158 188 version = "v1.1.2" 159 189 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" ··· 161 191 version = "v1.4.0" 162 192 hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 163 193 [mod."github.com/gorilla/websocket"] 164 - version = "v1.5.3" 165 - hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0=" 194 + version = "v1.5.4-0.20250319132907-e064f32e3674" 195 + hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to=" 196 + [mod."github.com/hashicorp/errwrap"] 197 + version = "v1.1.0" 198 + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" 166 199 [mod."github.com/hashicorp/go-cleanhttp"] 167 200 version = "v0.5.2" 168 201 hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 202 + [mod."github.com/hashicorp/go-multierror"] 203 + version = "v1.1.1" 204 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 169 205 [mod."github.com/hashicorp/go-retryablehttp"] 170 - version = "v0.7.7" 171 - hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU=" 206 + version = "v0.7.8" 207 + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 208 + [mod."github.com/hashicorp/go-secure-stdlib/parseutil"] 209 + version = "v0.2.0" 210 + hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8=" 211 + [mod."github.com/hashicorp/go-secure-stdlib/strutil"] 212 + version = "v0.1.2" 213 + hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A=" 214 + [mod."github.com/hashicorp/go-sockaddr"] 215 + version = "v1.0.7" 216 + hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 172 217 [mod."github.com/hashicorp/golang-lru"] 173 218 version = "v1.0.2" 174 219 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 175 220 [mod."github.com/hashicorp/golang-lru/v2"] 176 221 version = "v2.0.7" 177 222 hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 223 + [mod."github.com/hashicorp/hcl"] 224 + version = "v1.0.1-vault-7" 225 + hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM=" 226 + [mod."github.com/hexops/gotextdiff"] 227 + version = "v1.0.3" 228 + hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0=" 178 229 [mod."github.com/hiddeco/sshsig"] 179 230 version = "v0.2.0" 180 231 hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" ··· 185 236 version = "v0.0.4" 186 237 hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 187 238 [mod."github.com/ipfs/boxo"] 188 - version = "v0.30.0" 189 - hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848=" 239 + version = "v0.33.0" 240 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 190 241 [mod."github.com/ipfs/go-block-format"] 191 - version = "v0.2.1" 192 - hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8=" 242 + version = "v0.2.2" 243 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 193 244 [mod."github.com/ipfs/go-cid"] 194 245 version = "v0.5.0" 195 246 hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" ··· 203 254 version = "v1.1.1" 204 255 hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 205 256 [mod."github.com/ipfs/go-ipld-cbor"] 206 - version = "v0.2.0" 207 - hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc=" 257 + version = "v0.2.1" 258 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 208 259 [mod."github.com/ipfs/go-ipld-format"] 209 - version = "v0.6.1" 210 - hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4=" 260 + version = "v0.6.2" 261 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 211 262 [mod."github.com/ipfs/go-log"] 212 263 version = "v1.0.5" 213 264 hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" ··· 224 275 version = "v1.18.0" 225 276 hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 226 277 [mod."github.com/klauspost/cpuid/v2"] 227 - version = "v2.2.10" 228 - hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0=" 278 + version = "v2.3.0" 279 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 229 280 [mod."github.com/lestrrat-go/blackmagic"] 230 - version = "v1.0.3" 231 - hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw=" 281 + version = "v1.0.4" 282 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 232 283 [mod."github.com/lestrrat-go/httpcc"] 233 284 version = "v1.0.1" 234 285 hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" ··· 256 307 [mod."github.com/minio/sha256-simd"] 257 308 version = "v1.0.1" 258 309 hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 310 + [mod."github.com/mitchellh/mapstructure"] 311 + version = "v1.5.0" 312 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 259 313 [mod."github.com/moby/docker-image-spec"] 260 314 version = "v1.3.1" 261 315 hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" ··· 289 343 [mod."github.com/munnerz/goautoneg"] 290 344 version = "v0.0.0-20191010083416-a7dc8b61c822" 291 345 hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 346 + [mod."github.com/onsi/gomega"] 347 + version = "v1.37.0" 348 + hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o=" 349 + [mod."github.com/openbao/openbao/api/v2"] 350 + version = "v2.3.0" 351 + hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM=" 292 352 [mod."github.com/opencontainers/go-digest"] 293 353 version = "v1.0.0" 294 354 hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" ··· 296 356 version = "v1.1.1" 297 357 hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 298 358 [mod."github.com/opentracing/opentracing-go"] 299 - version = "v1.2.0" 300 - hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM=" 359 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 360 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 301 361 [mod."github.com/pjbgf/sha1cd"] 302 362 version = "v0.3.2" 303 363 hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" ··· 320 380 version = "v0.6.2" 321 381 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 322 382 [mod."github.com/prometheus/common"] 323 - version = "v0.63.0" 324 - hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE=" 383 + version = "v0.64.0" 384 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 325 385 [mod."github.com/prometheus/procfs"] 326 386 version = "v0.16.1" 327 387 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 328 388 [mod."github.com/redis/go-redis/v9"] 329 - version = "v9.3.0" 330 - hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w=" 389 + version = "v9.7.3" 390 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 331 391 [mod."github.com/resend/resend-go/v2"] 332 392 version = "v2.15.0" 333 393 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 394 + [mod."github.com/ryanuber/go-glob"] 395 + version = "v1.0.0" 396 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 334 397 [mod."github.com/segmentio/asm"] 335 398 version = "v1.2.0" 336 399 hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" ··· 375 438 version = "v1.1.0" 376 439 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 377 440 [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 378 - version = "v0.61.0" 379 - hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM=" 441 + version = "v0.62.0" 442 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 380 443 [mod."go.opentelemetry.io/otel"] 381 - version = "v1.36.0" 382 - hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko=" 444 + version = "v1.37.0" 445 + hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 446 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 447 + version = "v1.33.0" 448 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 383 449 [mod."go.opentelemetry.io/otel/metric"] 384 - version = "v1.36.0" 385 - hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8=" 450 + version = "v1.37.0" 451 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 386 452 [mod."go.opentelemetry.io/otel/trace"] 387 - version = "v1.36.0" 388 - hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA=" 453 + version = "v1.37.0" 454 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 389 455 [mod."go.opentelemetry.io/proto/otlp"] 390 456 version = "v1.6.0" 391 457 hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" ··· 399 465 version = "v1.27.0" 400 466 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 401 467 [mod."golang.org/x/crypto"] 402 - version = "v0.38.0" 403 - hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY=" 468 + version = "v0.40.0" 469 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 404 470 [mod."golang.org/x/exp"] 405 - version = "v0.0.0-20250408133849-7e4ce0ab07d0" 406 - hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8=" 471 + version = "v0.0.0-20250620022241-b7579e27df2b" 472 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 407 473 [mod."golang.org/x/net"] 408 - version = "v0.40.0" 409 - hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8=" 474 + version = "v0.42.0" 475 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 410 476 [mod."golang.org/x/sync"] 411 - version = "v0.14.0" 412 - hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4=" 477 + version = "v0.16.0" 478 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 413 479 [mod."golang.org/x/sys"] 414 - version = "v0.33.0" 415 - hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ=" 480 + version = "v0.34.0" 481 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 482 + [mod."golang.org/x/text"] 483 + version = "v0.27.0" 484 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 416 485 [mod."golang.org/x/time"] 417 - version = "v0.8.0" 418 - hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ=" 486 + version = "v0.12.0" 487 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 419 488 [mod."golang.org/x/xerrors"] 420 489 version = "v0.0.0-20240903120638-7835f813f4da" 421 490 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 422 491 [mod."google.golang.org/genproto/googleapis/api"] 423 - version = "v0.0.0-20250519155744-55703ea1f237" 424 - hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ=" 492 + version = "v0.0.0-20250603155806-513f23925822" 493 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 425 494 [mod."google.golang.org/genproto/googleapis/rpc"] 426 - version = "v0.0.0-20250519155744-55703ea1f237" 495 + version = "v0.0.0-20250603155806-513f23925822" 427 496 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 428 497 [mod."google.golang.org/grpc"] 429 - version = "v1.72.1" 430 - hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs=" 498 + version = "v1.73.0" 499 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 431 500 [mod."google.golang.org/protobuf"] 432 501 version = "v1.36.6" 433 502 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" ··· 450 519 version = "v1.4.1" 451 520 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 452 521 [mod."tangled.sh/icyphox.sh/atproto-oauth"] 453 - version = "v0.0.0-20250526154904-3906c5336421" 454 - hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM=" 522 + version = "v0.0.0-20250724194903-28e660378cb1" 523 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+54 -35
nix/modules/appview.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 - }: 7 - with lib; { 8 - options = { 9 - services.tangled-appview = { 10 - enable = mkOption { 11 - type = types.bool; 12 - default = false; 13 - description = "Enable tangled appview"; 14 - }; 15 - port = mkOption { 16 - type = types.int; 17 - default = 3000; 18 - description = "Port to run the appview on"; 19 - }; 20 - cookie_secret = mkOption { 21 - type = types.str; 22 - default = "00000000000000000000000000000000"; 23 - description = "Cookie secret"; 5 + }: let 6 + cfg = config.services.tangled-appview; 7 + in 8 + with lib; { 9 + options = { 10 + services.tangled-appview = { 11 + enable = mkOption { 12 + type = types.bool; 13 + default = false; 14 + description = "Enable tangled appview"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the appview"; 19 + }; 20 + port = mkOption { 21 + type = types.int; 22 + default = 3000; 23 + description = "Port to run the appview on"; 24 + }; 25 + cookie_secret = mkOption { 26 + type = types.str; 27 + default = "00000000000000000000000000000000"; 28 + description = "Cookie secret"; 29 + }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 24 43 }; 25 44 }; 26 - }; 27 45 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 46 + config = mkIf cfg.enable { 47 + systemd.services.tangled-appview = { 48 + description = "tangled appview service"; 49 + wantedBy = ["multi-user.target"]; 32 50 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 - }; 51 + serviceConfig = { 52 + ListenStream = "0.0.0.0:${toString cfg.port}"; 53 + ExecStart = "${cfg.package}/bin/appview"; 54 + Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 56 + }; 38 57 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 58 + environment = { 59 + TANGLED_DB_PATH = "appview.db"; 60 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 + }; 42 62 }; 43 63 }; 44 - }; 45 - } 64 + }
+45 -7
nix/modules/knot.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 3 pkgs, 4 4 lib, ··· 13 13 type = types.bool; 14 14 default = false; 15 15 description = "Enable a tangled knot"; 16 + }; 17 + 18 + package = mkOption { 19 + type = types.package; 20 + description = "Package to use for the knot"; 16 21 }; 17 22 18 23 appviewEndpoint = mkOption { ··· 53 58 }; 54 59 }; 55 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 56 83 server = { 57 84 listenAddr = mkOption { 58 85 type = types.str; ··· 94 121 }; 95 122 96 123 config = mkIf cfg.enable { 97 - environment.systemPackages = with pkgs; [ 98 - git 99 - self.packages."${pkgs.system}".knot 124 + environment.systemPackages = [ 125 + pkgs.git 126 + cfg.package 100 127 ]; 101 128 102 - system.activationScripts.gitConfig = '' 129 + system.activationScripts.gitConfig = let 130 + setMotd = 131 + if cfg.motdFile != null && cfg.motd != null 132 + then throw "motdFile and motd cannot be both set" 133 + else '' 134 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 135 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 136 + ''; 137 + in '' 103 138 mkdir -p "${cfg.repo.scanPath}" 104 139 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 105 140 ··· 108 143 [user] 109 144 name = Git User 110 145 email = git@example.com 146 + [receive] 147 + advertisePushOptions = true 111 148 EOF 149 + ${setMotd} 112 150 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 113 151 ''; 114 152 ··· 135 173 mode = "0555"; 136 174 text = '' 137 175 #!${pkgs.stdenv.shell} 138 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 176 + ${cfg.package}/bin/knot keys \ 139 177 -output authorized-keys \ 140 178 -internal-api "http://${cfg.server.internalListenAddr}" \ 141 179 -git-dir "${cfg.repo.scanPath}" \ ··· 160 198 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 161 199 ]; 162 200 EnvironmentFile = cfg.server.secretFile; 163 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 201 + ExecStart = "${cfg.package}/bin/knot server"; 164 202 Restart = "always"; 165 203 }; 166 204 };
+28 -3
nix/modules/spindle.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 5 }: let ··· 13 12 type = types.bool; 14 13 default = false; 15 14 description = "Enable a tangled spindle"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the spindle"; 16 19 }; 17 20 18 21 server = { ··· 51 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 52 55 description = "DID of owner (required)"; 53 56 }; 57 + 58 + secrets = { 59 + provider = mkOption { 60 + type = types.str; 61 + default = "sqlite"; 62 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 63 + }; 64 + 65 + openbao = { 66 + proxyAddr = mkOption { 67 + type = types.str; 68 + default = "http://127.0.0.1:8200"; 69 + }; 70 + mount = mkOption { 71 + type = types.str; 72 + default = "spindle"; 73 + }; 74 + }; 75 + }; 54 76 }; 55 77 56 78 pipelines = { ··· 86 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 87 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 88 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 111 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 89 114 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 115 "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 91 116 ]; 92 - ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 117 + ExecStart = "${cfg.package}/bin/spindle"; 93 118 Restart = "always"; 94 119 }; 95 120 };
+29
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 19 + mkdir -p $out/{fonts,icons} && cd $out 20 + cp -f ${htmx-src} htmx.min.js 21 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 + cp -rf ${lucide-src}/*.svg icons/ 23 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/ 26 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 + # for whatever reason (produces broken css), so we are doing this instead 28 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 29 + ''
+5 -17
nix/pkgs/appview.nix
··· 1 1 { 2 2 buildGoApplication, 3 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 4 + appview-static-files, 10 5 sqlite-lib, 11 - gitignoreSource, 6 + src, 12 7 }: 13 8 buildGoApplication { 14 9 pname = "appview"; 15 10 version = "0.1.0"; 16 - src = gitignoreSource ../..; 17 - inherit modules; 11 + inherit src modules; 18 12 19 13 postUnpack = '' 20 14 pushd source 21 - mkdir -p appview/pages/static/{fonts,icons} 22 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 23 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 24 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 25 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 26 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 27 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 28 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 29 17 popd 30 18 ''; 31 19
+7 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - gitignoreSource, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - src = gitignoreSource ../..; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 10 15 inherit modules; 11 - subPackages = ["cmd/genjwks"]; 12 16 doCheck = false; 13 17 CGO_ENABLED = 0; 14 18 }
+2 -3
nix/pkgs/knot-unwrapped.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "knot"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+81 -62
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 3 4 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - self.nixosModules.spindle 10 - ({ 11 - config, 12 - pkgs, 13 - ... 14 - }: { 15 - virtualisation = { 16 - memorySize = 2048; 17 - diskSize = 10 * 1024; 18 - cores = 2; 19 - forwardPorts = [ 20 - # ssh 21 - { 22 - from = "host"; 23 - host.port = 2222; 24 - guest.port = 22; 25 - } 26 - # knot 27 - { 28 - from = "host"; 29 - host.port = 6000; 30 - guest.port = 6000; 31 - } 32 - # spindle 33 - { 34 - from = "host"; 35 - host.port = 6555; 36 - guest.port = 6555; 37 - } 5 + }: let 6 + envVar = name: let 7 + var = builtins.getEnv name; 8 + in 9 + if var == "" 10 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 11 + else var; 12 + in 13 + nixpkgs.lib.nixosSystem { 14 + inherit system; 15 + modules = [ 16 + self.nixosModules.knot 17 + self.nixosModules.spindle 18 + ({ 19 + config, 20 + pkgs, 21 + ... 22 + }: { 23 + nixos-shell = { 24 + inheritPath = false; 25 + mounts = { 26 + mountHome = false; 27 + mountNixProfile = false; 28 + }; 29 + }; 30 + virtualisation = { 31 + memorySize = 2048; 32 + diskSize = 10 * 1024; 33 + cores = 2; 34 + forwardPorts = [ 35 + # ssh 36 + { 37 + from = "host"; 38 + host.port = 2222; 39 + guest.port = 22; 40 + } 41 + # knot 42 + { 43 + from = "host"; 44 + host.port = 6000; 45 + guest.port = 6000; 46 + } 47 + # spindle 48 + { 49 + from = "host"; 50 + host.port = 6555; 51 + guest.port = 6555; 52 + } 53 + ]; 54 + }; 55 + services.getty.autologinUser = "root"; 56 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 57 + systemd.tmpfiles.rules = let 58 + u = config.services.tangled-knot.gitUser; 59 + g = config.services.tangled-knot.gitUser; 60 + in [ 61 + "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 62 + "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}" 38 63 ]; 39 - }; 40 - services.getty.autologinUser = "root"; 41 - environment.systemPackages = with pkgs; [curl vim git]; 42 - systemd.tmpfiles.rules = let 43 - u = config.services.tangled-knot.gitUser; 44 - g = config.services.tangled-knot.gitUser; 45 - in [ 46 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 47 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440" 48 - ]; 49 - services.tangled-knot = { 50 - enable = true; 51 - server = { 52 - secretFile = "/var/lib/knot/secret"; 53 - hostname = "localhost:6000"; 54 - listenAddr = "0.0.0.0:6000"; 64 + services.tangled-knot = { 65 + enable = true; 66 + motd = "Welcome to the development knot!\n"; 67 + server = { 68 + secretFile = "/var/lib/knot/secret"; 69 + hostname = "localhost:6000"; 70 + listenAddr = "0.0.0.0:6000"; 71 + }; 55 72 }; 56 - }; 57 - services.tangled-spindle = { 58 - enable = true; 59 - server = { 60 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 61 - hostname = "localhost:6555"; 62 - listenAddr = "0.0.0.0:6555"; 63 - dev = true; 73 + services.tangled-spindle = { 74 + enable = true; 75 + server = { 76 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 77 + hostname = "localhost:6555"; 78 + listenAddr = "0.0.0.0:6555"; 79 + dev = true; 80 + secrets = { 81 + provider = "sqlite"; 82 + }; 83 + }; 64 84 }; 65 - }; 66 - }) 67 - ]; 68 - } 85 + }) 86 + ]; 87 + }
+4
rbac/rbac.go
··· 11 11 ) 12 12 13 13 const ( 14 + ThisServer = "thisserver" // resource identifier for local rbac enforcement 15 + ) 16 + 17 + const ( 14 18 Model = ` 15 19 [request_definition] 16 20 r = sub, dom, obj, act
+23 -6
spindle/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 9 11 type Server struct { 10 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 11 - DBPath string `env:"DB_PATH, default=spindle.db"` 12 - Hostname string `env:"HOSTNAME, required"` 13 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 14 - Dev bool `env:"DEV, default=false"` 15 - Owner string `env:"OWNER, required"` 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 + DBPath string `env:"DB_PATH, default=spindle.db"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + Dev bool `env:"DEV, default=false"` 17 + Owner string `env:"OWNER, required"` 18 + Secrets Secrets `env:",prefix=SECRETS_"` 19 + } 20 + 21 + func (s Server) Did() syntax.DID { 22 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 23 + } 24 + 25 + type Secrets struct { 26 + Provider string `env:"PROVIDER, default=sqlite"` 27 + OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` 28 + } 29 + 30 + type OpenBaoConfig struct { 31 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 32 + Mount string `env:"MOUNT, default=spindle"` 16 33 } 17 34 18 35 type Pipelines struct {
+15
spindle/db/db.go
··· 45 45 unique(owner, name) 46 46 ); 47 47 48 + create table if not exists spindle_members ( 49 + -- identifiers for the record 50 + id integer primary key autoincrement, 51 + did text not null, 52 + rkey text not null, 53 + 54 + -- data 55 + instance text not null, 56 + subject text not null, 57 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 58 + 59 + -- constraints 60 + unique (did, instance, subject) 61 + ); 62 + 48 63 -- status event for a single workflow 49 64 create table if not exists events ( 50 65 rkey text not null,
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
+42 -19
spindle/engine/engine.go
··· 11 11 "sync" 12 12 "time" 13 13 14 + securejoin "github.com/cyphar/filepath-securejoin" 14 15 "github.com/docker/docker/api/types/container" 15 16 "github.com/docker/docker/api/types/image" 16 17 "github.com/docker/docker/api/types/mount" ··· 18 19 "github.com/docker/docker/api/types/volume" 19 20 "github.com/docker/docker/client" 20 21 "github.com/docker/docker/pkg/stdcopy" 22 + "golang.org/x/sync/errgroup" 21 23 "tangled.sh/tangled.sh/core/log" 22 24 "tangled.sh/tangled.sh/core/notifier" 23 25 "tangled.sh/tangled.sh/core/spindle/config" 24 26 "tangled.sh/tangled.sh/core/spindle/db" 25 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 29 ) 27 30 28 31 const ( ··· 37 40 db *db.DB 38 41 n *notifier.Notifier 39 42 cfg *config.Config 43 + vault secrets.Manager 40 44 41 45 cleanupMu sync.Mutex 42 46 cleanup map[string][]cleanupFunc 43 47 } 44 48 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 49 + func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 46 50 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 51 if err != nil { 48 52 return nil, err ··· 56 60 db: db, 57 61 n: n, 58 62 cfg: cfg, 63 + vault: vault, 59 64 } 60 65 61 66 e.cleanup = make(map[string][]cleanupFunc) ··· 66 71 func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 72 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 73 69 - wg := sync.WaitGroup{} 74 + // extract secrets 75 + var allSecrets []secrets.UnlockedSecret 76 + if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 + if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 + allSecrets = res 79 + } 80 + } 81 + 82 + workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 83 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 84 + if err != nil { 85 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 86 + workflowTimeout = 5 * time.Minute 87 + } 88 + e.l.Info("using workflow timeout", "timeout", workflowTimeout) 89 + 90 + eg, ctx := errgroup.WithContext(ctx) 70 91 for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 92 + eg.Go(func() error { 74 93 wid := models.WorkflowId{ 75 94 PipelineId: pipelineId, 76 95 Name: w.Name, ··· 102 121 defer reader.Close() 103 122 io.Copy(os.Stdout, reader) 104 123 105 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 106 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 107 - if err != nil { 108 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 109 - workflowTimeout = 5 * time.Minute 110 - } 111 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 112 124 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 125 defer cancel() 114 126 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 127 + err = e.StartSteps(ctx, wid, w, allSecrets) 116 128 if err != nil { 117 129 if errors.Is(err, ErrTimedOut) { 118 130 dbErr := e.db.StatusTimeout(wid, e.n) ··· 135 147 } 136 148 137 149 return nil 138 - }() 150 + }) 139 151 } 140 152 141 - wg.Wait() 153 + if err = eg.Wait(); err != nil { 154 + e.l.Error("failed to run one or more workflows", "err", err) 155 + } else { 156 + e.l.Error("successfully ran full pipeline") 157 + } 142 158 } 143 159 144 160 // SetupWorkflow sets up a new network for the workflow and volumes for ··· 186 202 // ONLY marks pipeline as failed if container's exit code is non-zero. 187 203 // All other errors are bubbled up. 188 204 // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 205 + func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 206 + workflowEnvs := ConstructEnvs(w.Environment) 207 + for _, s := range secrets { 208 + workflowEnvs.AddEnv(s.Key, s.Value) 209 + } 190 210 191 - for stepIdx, step := range steps { 211 + for stepIdx, step := range w.Steps { 192 212 select { 193 213 case <-ctx.Done(): 194 214 return ctx.Err() 195 215 default: 196 216 } 197 217 198 - envs := ConstructEnvs(step.Environment) 218 + envs := append(EnvVars(nil), workflowEnvs...) 219 + for k, v := range step.Environment { 220 + envs.AddEnv(k, v) 221 + } 199 222 envs.AddEnv("HOME", workspaceDir) 200 223 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 224 202 225 hostConfig := hostConfig(wid) 203 226 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 227 + Image: w.Image, 205 228 Cmd: []string{"bash", "-c", step.Command}, 206 229 WorkingDir: workspaceDir, 207 230 Tty: false,
+168 -6
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 8 + "time" 7 9 8 10 "tangled.sh/tangled.sh/core/api/tangled" 9 11 "tangled.sh/tangled.sh/core/eventconsumer" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 10 15 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/identity" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + "github.com/bluesky-social/indigo/xrpc" 11 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 12 22 ) 13 23 14 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 33 43 s.ingestMember(ctx, e) 34 44 case tangled.RepoNSID: 35 45 s.ingestRepo(ctx, e) 46 + case tangled.RepoCollaboratorNSID: 47 + s.ingestCollaborator(ctx, e) 36 48 } 37 49 38 50 return err ··· 40 52 } 41 53 42 54 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 55 + var err error 43 56 did := e.Did 44 - var err error 57 + rkey := e.Commit.RKey 45 58 46 59 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 47 60 ··· 56 69 } 57 70 58 71 domain := s.cfg.Server.Hostname 59 - if s.cfg.Server.Dev { 60 - domain = s.cfg.Server.ListenAddr 61 - } 62 72 recordInstance := record.Instance 63 73 64 74 if recordInstance != domain { ··· 72 82 return fmt.Errorf("failed to enforce permissions: %w", err) 73 83 } 74 84 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 85 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 86 + Did: syntax.DID(did), 87 + Rkey: rkey, 88 + Instance: recordInstance, 89 + Subject: syntax.DID(record.Subject), 90 + Created: time.Now(), 91 + }); err != nil { 92 + l.Error("failed to add member", "error", err) 93 + return fmt.Errorf("failed to add member: %w", err) 94 + } 95 + 96 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 76 97 l.Error("failed to add member", "error", err) 77 98 return fmt.Errorf("failed to add member: %w", err) 78 99 } ··· 86 107 87 108 return nil 88 109 110 + case models.CommitOperationDelete: 111 + record, err := db.GetSpindleMember(s.db, did, rkey) 112 + if err != nil { 113 + l.Error("failed to find member", "error", err) 114 + return fmt.Errorf("failed to find member: %w", err) 115 + } 116 + 117 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 118 + l.Error("failed to remove member", "error", err) 119 + return fmt.Errorf("failed to remove member: %w", err) 120 + } 121 + 122 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 123 + l.Error("failed to add member", "error", err) 124 + return fmt.Errorf("failed to add member: %w", err) 125 + } 126 + l.Info("added member from firehose", "member", record.Subject) 127 + 128 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 129 + l.Error("failed to add did", "error", err) 130 + return fmt.Errorf("failed to add did: %w", err) 131 + } 132 + s.jc.RemoveDid(record.Subject.String()) 133 + 89 134 } 90 135 return nil 91 136 } 92 137 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 138 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 94 139 var err error 140 + did := e.Did 141 + resolver := idresolver.DefaultResolver() 95 142 96 143 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 144 ··· 127 174 return fmt.Errorf("failed to add repo: %w", err) 128 175 } 129 176 177 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 178 + if err != nil { 179 + return err 180 + } 181 + 182 + // add repo to rbac 183 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 184 + l.Error("failed to add repo to enforcer", "error", err) 185 + return fmt.Errorf("failed to add repo: %w", err) 186 + } 187 + 188 + // add collaborators to rbac 189 + owner, err := resolver.ResolveIdent(ctx, did) 190 + if err != nil || owner.Handle.IsInvalidHandle() { 191 + return err 192 + } 193 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 194 + return err 195 + } 196 + 130 197 // add this knot to the event consumer 131 198 src := eventconsumer.NewKnotSource(record.Knot) 132 199 s.ks.AddSource(context.Background(), src) ··· 136 203 } 137 204 return nil 138 205 } 206 + 207 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 208 + var err error 209 + 210 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 211 + 212 + l.Info("ingesting collaborator record") 213 + 214 + switch e.Commit.Operation { 215 + case models.CommitOperationCreate, models.CommitOperationUpdate: 216 + raw := e.Commit.Record 217 + record := tangled.RepoCollaborator{} 218 + err = json.Unmarshal(raw, &record) 219 + if err != nil { 220 + l.Error("invalid record", "error", err) 221 + return err 222 + } 223 + 224 + resolver := idresolver.DefaultResolver() 225 + 226 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 227 + if err != nil || subjectId.Handle.IsInvalidHandle() { 228 + return err 229 + } 230 + 231 + repoAt, err := syntax.ParseATURI(record.Repo) 232 + if err != nil { 233 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 + return nil 235 + } 236 + 237 + // TODO: get rid of this entirely 238 + // resolve this aturi to extract the repo record 239 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 240 + if err != nil || owner.Handle.IsInvalidHandle() { 241 + return fmt.Errorf("failed to resolve handle: %w", err) 242 + } 243 + 244 + xrpcc := xrpc.Client{ 245 + Host: owner.PDSEndpoint(), 246 + } 247 + 248 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + repo := resp.Value.Val.(*tangled.Repo) 254 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 + 256 + // check perms for this user 257 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 + return fmt.Errorf("insufficient permissions: %w", err) 259 + } 260 + 261 + // add collaborator to rbac 262 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 + l.Error("failed to add repo to enforcer", "error", err) 264 + return fmt.Errorf("failed to add repo: %w", err) 265 + } 266 + 267 + return nil 268 + } 269 + return nil 270 + } 271 + 272 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 + 275 + l.Info("fetching and adding existing collaborators") 276 + 277 + xrpcc := xrpc.Client{ 278 + Host: owner.PDSEndpoint(), 279 + } 280 + 281 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 + if err != nil { 283 + return err 284 + } 285 + 286 + var errs error 287 + for _, r := range resp.Records { 288 + if r == nil { 289 + continue 290 + } 291 + record := r.Value.Val.(*tangled.RepoCollaborator) 292 + 293 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 + l.Error("failed to add repo to enforcer", "error", err) 295 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 + } 297 + } 298 + 299 + return errs 300 + }
+9 -12
spindle/models/pipeline.go
··· 8 8 ) 9 9 10 10 type Pipeline struct { 11 + RepoOwner string 12 + RepoName string 11 13 Workflows []Workflow 12 14 } 13 15 ··· 63 65 swf.Environment = workflowEnvToMap(twf.Environment) 64 66 swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 67 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 68 setup := &setupSteps{} 69 69 70 70 setup.addStep(nixConfStep()) ··· 79 79 80 80 workflows = append(workflows, *swf) 81 81 } 82 - return &Pipeline{Workflows: workflows} 82 + repoOwner := pl.TriggerMetadata.Repo.Did 83 + repoName := pl.TriggerMetadata.Repo.Repo 84 + return &Pipeline{ 85 + RepoOwner: repoOwner, 86 + RepoName: repoName, 87 + Workflows: workflows, 88 + } 83 89 } 84 90 85 91 func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { ··· 115 121 116 122 return path.Join(nixery, dependencies) 117 123 } 118 - 119 - func (wf *Workflow) addNixProfileToPath() { 120 - wf.Environment["PATH"] = "$PATH:/.nix-profile/bin" 121 - } 122 - 123 - func (wf *Workflow) setGlobalEnvs() { 124 - wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes" 125 - wf.Environment["HOME"] = "/tangled/workspace" 126 - }
+3
spindle/models/setup_steps.go
··· 102 102 continue 103 103 } 104 104 105 + if len(packages) == 0 { 106 + customPackages = append(customPackages, registry) 107 + } 105 108 // collect packages from custom registries 106 109 for _, pkg := range packages { 107 110 customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
+25
spindle/motd
··· 1 + **** 2 + *** *** 3 + *** ** ****** ** 4 + ** * ***** 5 + * ** ** 6 + * * * *************** 7 + ** ** *# ** 8 + * ** ** *** ** 9 + * * ** ** * ****** 10 + * ** ** * ** * * 11 + ** ** *** ** ** * 12 + ** ** * ** * * 13 + ** **** ** * * 14 + ** *** ** ** ** 15 + *** ** ***** 16 + ******************** 17 + ** 18 + * 19 + #************** 20 + ** 21 + ******** 22 + 23 + This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 + 25 + Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "regexp" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + type DidSlashRepo string 13 + 14 + type Secret[T any] struct { 15 + Key string 16 + Value T 17 + Repo DidSlashRepo 18 + CreatedAt time.Time 19 + CreatedBy syntax.DID 20 + } 21 + 22 + // the secret is not present 23 + type LockedSecret = Secret[struct{}] 24 + 25 + // the secret is present in plaintext, never expose this publicly, 26 + // only use in the workflow engine 27 + type UnlockedSecret = Secret[string] 28 + 29 + type Manager interface { 30 + AddSecret(ctx context.Context, secret UnlockedSecret) error 31 + RemoveSecret(ctx context.Context, secret Secret[any]) error 32 + GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) 33 + GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) 34 + } 35 + 36 + // stopper interface for managers that need cleanup 37 + type Stopper interface { 38 + Stop() 39 + } 40 + 41 + var ErrKeyAlreadyPresent = errors.New("key already present") 42 + var ErrInvalidKeyIdent = errors.New("key is not a valid identifier") 43 + var ErrKeyNotFound = errors.New("key not found") 44 + 45 + // ensure that we are satisfying the interface 46 + var ( 47 + _ = []Manager{ 48 + &SqliteManager{}, 49 + &OpenBaoManager{}, 50 + } 51 + ) 52 + 53 + var ( 54 + // bash identifier syntax 55 + keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) 56 + ) 57 + 58 + func isValidKey(key string) bool { 59 + if key == "" { 60 + return false 61 + } 62 + return keyIdent.MatchString(key) 63 + } 64 + 65 + func ValidateKey(key string) error { 66 + if !isValidKey(key) { 67 + return ErrInvalidKeyIdent 68 + } 69 + return nil 70 + }
+313
spindle/secrets/openbao.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "path" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + vault "github.com/openbao/openbao/api/v2" 13 + ) 14 + 15 + type OpenBaoManager struct { 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + } 20 + 21 + type OpenBaoManagerOpt func(*OpenBaoManager) 22 + 23 + func WithMountPath(mountPath string) OpenBaoManagerOpt { 24 + return func(v *OpenBaoManager) { 25 + v.mountPath = mountPath 26 + } 27 + } 28 + 29 + // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 + // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 + // The proxy handles all authentication automatically via Auto-Auth 32 + func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 + if proxyAddress == "" { 34 + return nil, fmt.Errorf("proxy address cannot be empty") 35 + } 36 + 37 + config := vault.DefaultConfig() 38 + config.Address = proxyAddress 39 + 40 + client, err := vault.NewClient(config) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create openbao client: %w", err) 43 + } 44 + 45 + manager := &OpenBaoManager{ 46 + client: client, 47 + mountPath: "spindle", // default KV v2 mount path 48 + logger: logger, 49 + } 50 + 51 + for _, opt := range opts { 52 + opt(manager) 53 + } 54 + 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 57 + } 58 + 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 61 + } 62 + 63 + // testConnection verifies that we can connect to the proxy 64 + func (v *OpenBaoManager) testConnection() error { 65 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 + defer cancel() 67 + 68 + // try token self-lookup as a quick way to verify proxy works 69 + // and is authenticated 70 + _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 71 + if err != nil { 72 + return fmt.Errorf("proxy connection test failed: %w", err) 73 + } 74 + 75 + return nil 76 + } 77 + 78 + func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 79 + if err := ValidateKey(secret.Key); err != nil { 80 + return err 81 + } 82 + 83 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 85 + 86 + // Check if secret already exists 87 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 88 + if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 90 + return ErrKeyAlreadyPresent 91 + } 92 + 93 + secretData := map[string]interface{}{ 94 + "value": secret.Value, 95 + "repo": string(secret.Repo), 96 + "key": secret.Key, 97 + "created_at": secret.CreatedAt.Format(time.RFC3339), 98 + "created_by": secret.CreatedBy.String(), 99 + } 100 + 101 + v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 + resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 103 + if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 105 + return fmt.Errorf("failed to store secret in openbao: %w", err) 106 + } 107 + 108 + v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 + 110 + v.logger.Debug("verifying secret was written", "path", secretPath) 111 + readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 + if err != nil { 113 + v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 + return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 + } 116 + 117 + if readBack == nil || readBack.Data == nil { 118 + v.logger.Error("secret verification returned empty data", "path", secretPath) 119 + return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 + } 121 + 122 + v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 123 + return nil 124 + } 125 + 126 + func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 127 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 128 + 129 + // check if secret exists 130 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 131 + if err != nil || existing == nil { 132 + return ErrKeyNotFound 133 + } 134 + 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(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 + }
+95 -43
spindle/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + _ "embed" 5 6 "encoding/json" 6 7 "fmt" 7 8 "log/slog" ··· 11 12 "tangled.sh/tangled.sh/core/api/tangled" 12 13 "tangled.sh/tangled.sh/core/eventconsumer" 13 14 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 + "tangled.sh/tangled.sh/core/idresolver" 14 16 "tangled.sh/tangled.sh/core/jetstream" 15 17 "tangled.sh/tangled.sh/core/log" 16 18 "tangled.sh/tangled.sh/core/notifier" ··· 20 22 "tangled.sh/tangled.sh/core/spindle/engine" 21 23 "tangled.sh/tangled.sh/core/spindle/models" 22 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 + "tangled.sh/tangled.sh/core/spindle/xrpc" 23 27 ) 24 28 29 + //go:embed motd 30 + var motd []byte 31 + 25 32 const ( 26 33 rbacDomain = "thisserver" 27 34 ) 28 35 29 36 type Spindle struct { 30 - jc *jetstream.JetstreamClient 31 - db *db.DB 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 35 - eng *engine.Engine 36 - jq *queue.Queue 37 - cfg *config.Config 38 - ks *eventconsumer.Consumer 37 + jc *jetstream.JetstreamClient 38 + db *db.DB 39 + e *rbac.Enforcer 40 + l *slog.Logger 41 + n *notifier.Notifier 42 + eng *engine.Engine 43 + jq *queue.Queue 44 + cfg *config.Config 45 + ks *eventconsumer.Consumer 46 + res *idresolver.Resolver 47 + vault secrets.Manager 39 48 } 40 49 41 50 func Run(ctx context.Context) error { ··· 59 68 60 69 n := notifier.New() 61 70 62 - eng, err := engine.New(ctx, cfg, d, &n) 71 + var vault secrets.Manager 72 + switch cfg.Server.Secrets.Provider { 73 + case "openbao": 74 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 75 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 76 + } 77 + vault, err = secrets.NewOpenBaoManager( 78 + cfg.Server.Secrets.OpenBao.ProxyAddr, 79 + logger, 80 + secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 81 + ) 82 + if err != nil { 83 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 84 + } 85 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 86 + case "sqlite", "": 87 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 88 + if err != nil { 89 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 90 + } 91 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 92 + default: 93 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 + } 95 + 96 + eng, err := engine.New(ctx, cfg, d, &n, vault) 63 97 if err != nil { 64 98 return err 65 99 } 66 100 67 - jq := queue.NewQueue(100, 2) 101 + jq := queue.NewQueue(100, 5) 68 102 69 103 collections := []string{ 70 104 tangled.SpindleMemberNSID, 71 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 72 107 } 73 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 109 if err != nil { ··· 76 111 } 77 112 jc.AddDid(cfg.Server.Owner) 78 113 114 + // Check if the spindle knows about any Dids; 115 + dids, err := d.GetAllDids() 116 + if err != nil { 117 + return fmt.Errorf("failed to get all dids: %w", err) 118 + } 119 + for _, d := range dids { 120 + jc.AddDid(d) 121 + } 122 + 123 + resolver := idresolver.DefaultResolver() 124 + 79 125 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, 126 + jc: jc, 127 + e: e, 128 + db: d, 129 + l: logger, 130 + n: &n, 131 + eng: eng, 132 + jq: jq, 133 + cfg: cfg, 134 + res: resolver, 135 + vault: vault, 88 136 } 89 137 90 138 err = e.AddSpindle(rbacDomain) ··· 100 148 // starts a job queue runner in the background 101 149 jq.Start() 102 150 defer jq.Stop() 151 + 152 + // Stop vault token renewal if it implements Stopper 153 + if stopper, ok := vault.(secrets.Stopper); ok { 154 + defer stopper.Stop() 155 + } 103 156 104 157 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 158 if err != nil { ··· 144 197 mux := chi.NewRouter() 145 198 146 199 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`)) 200 + w.Write(motd) 171 201 }) 172 202 mux.HandleFunc("/events", s.Events) 173 203 mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 174 204 w.Write([]byte(s.cfg.Server.Owner)) 175 205 }) 176 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 207 + 208 + mux.Mount("/xrpc", s.XrpcRouter()) 177 209 return mux 178 210 } 179 211 212 + func (s *Spindle) XrpcRouter() http.Handler { 213 + logger := s.l.With("route", "xrpc") 214 + 215 + x := xrpc.Xrpc{ 216 + Logger: logger, 217 + Db: s.db, 218 + Enforcer: s.e, 219 + Engine: s.eng, 220 + Config: s.cfg, 221 + Resolver: s.res, 222 + Vault: s.vault, 223 + } 224 + 225 + return x.Router() 226 + } 227 + 180 228 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 181 229 if msg.Nsid == tangled.PipelineNSID { 182 230 tpl := tangled.Pipeline{} ··· 192 240 193 241 if tpl.TriggerMetadata.Repo == nil { 194 242 return fmt.Errorf("no repo data found") 243 + } 244 + 245 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 246 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 195 247 } 196 248 197 249 // filter by repos
+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 + }
+6
tailwind.config.js
··· 40 40 color: colors.black, 41 41 "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 42 42 }, 43 + li: { 44 + "@apply inline-block w-full my-0 py-0": {}, 45 + }, 46 + "ul, ol": { 47 + "@apply my-1 py-0": {}, 48 + }, 43 49 code: { 44 50 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {}, 45 51 },