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

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc
+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 + }
+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 + }
+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 + }
+9 -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 {
+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 + }
+72 -2
appview/db/db.go
··· 443 443 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 444 444 ); 445 445 446 + create table if not exists strings ( 447 + -- identifiers 448 + did text not null, 449 + rkey text not null, 450 + 451 + -- content 452 + filename text not null, 453 + description text, 454 + content text not null, 455 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 456 + edited text, 457 + 458 + primary key (did, rkey) 459 + ); 460 + 446 461 create table if not exists migrations ( 447 462 id integer primary key autoincrement, 448 463 name text unique ··· 585 600 return nil 586 601 }) 587 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 + 588 658 return &DB{db}, nil 589 659 } 590 660 ··· 658 728 kind := rv.Kind() 659 729 660 730 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 661 - if kind == reflect.Slice || kind == reflect.Array { 731 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 662 732 if rv.Len() == 0 { 663 733 // always false 664 734 return "1 = 0" ··· 678 748 func (f filter) Arg() []any { 679 749 rv := reflect.ValueOf(f.arg) 680 750 kind := rv.Kind() 681 - if kind == reflect.Slice || kind == reflect.Array { 751 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 682 752 if rv.Len() == 0 { 683 753 return nil 684 754 }
-34
appview/db/repos.go
··· 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) ··· 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 {
+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 + }
+60
appview/ingester.go
··· 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 } ··· 385 387 if err != nil { 386 388 return fmt.Errorf("failed to update ACLs: %w", err) 387 389 } 390 + 391 + l.Info("added spindle member") 388 392 case models.CommitOperationDelete: 389 393 rkey := e.Commit.RKey 390 394 ··· 431 435 if err = i.Enforcer.E.SavePolicy(); err != nil { 432 436 return fmt.Errorf("failed to save ACLs: %w", err) 433 437 } 438 + 439 + l.Info("removed spindle member") 434 440 } 435 441 436 442 return nil ··· 549 555 550 556 return nil 551 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 + }
+2 -10
appview/middleware/middleware.go
··· 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 ··· 187 177 next.ServeHTTP(w, req) 188 178 return 189 179 } 180 + 181 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 190 182 191 183 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 184 if err != nil {
+141
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" ··· 23 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) {
+95 -4
appview/pages/pages.go
··· 31 31 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 32 32 "github.com/alecthomas/chroma/v2/lexers" 33 33 "github.com/alecthomas/chroma/v2/styles" 34 + "github.com/bluesky-social/indigo/atproto/identity" 34 35 "github.com/bluesky-social/indigo/atproto/syntax" 35 36 "github.com/go-git/go-git/v5/plumbing" 36 37 "github.com/go-git/go-git/v5/plumbing/object" ··· 262 263 return p.executePlain("user/login", w, params) 263 264 } 264 265 265 - type SignupParams struct{} 266 + func (p *Pages) Signup(w io.Writer) error { 267 + return p.executePlain("user/signup", w, nil) 268 + } 266 269 267 - func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 - return p.executePlain("user/completeSignup", w, params) 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) 269 288 } 270 289 271 290 type TimelineParams struct { ··· 397 416 UserDid string 398 417 UserHandle string 399 418 FollowStatus db.FollowStatus 400 - AvatarUri string 401 419 Followers int 402 420 Following int 403 421 ··· 1124 1142 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1125 1143 params.Active = "pipelines" 1126 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) 1127 1218 } 1128 1219 1129 1220 func (p *Pages) Static() http.Handler {
+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 }}
+25 -16
appview/pages/templates/layouts/topbar.html
··· 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 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"> 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 45 <details class="relative inline-block text-left"> 38 46 <summary ··· 46 54 > 47 55 <a href="/{{ $user }}">profile</a> 48 56 <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 49 58 <a href="/knots">knots</a> 50 59 <a href="/spindles">spindles</a> 51 60 <a href="/settings">settings</a>
+133
appview/pages/templates/legal/privacy.html
··· 1 + {{ define "title" }} privacy policy {{ end }} 2 + {{ define "content" }} 3 + <div class="max-w-4xl mx-auto px-4 py-8"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 + <h1>Privacy Policy</h1> 7 + 8 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 + 10 + <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 + 12 + <h2>1. Information We Collect</h2> 13 + 14 + <h3>Account Information</h3> 15 + <p>When you create an account, we collect:</p> 16 + <ul> 17 + <li>Your chosen username</li> 18 + <li>Email address</li> 19 + <li>Profile information you choose to provide</li> 20 + <li>Authentication data</li> 21 + </ul> 22 + 23 + <h3>Content and Activity</h3> 24 + <p>We store:</p> 25 + <ul> 26 + <li>Code repositories and associated metadata</li> 27 + <li>Issues, pull requests, and comments</li> 28 + <li>Activity logs and usage patterns</li> 29 + <li>Public keys for authentication</li> 30 + </ul> 31 + 32 + <h2>2. Data Location and Hosting</h2> 33 + <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 + <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 + <p class="text-blue-700 dark:text-blue-300"> 36 + <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 + </p> 38 + <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 + <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 + <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 + <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 + </ul> 43 + </div> 44 + 45 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 + <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 + <p class="text-yellow-700 dark:text-yellow-300"> 48 + <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 + </p> 50 + </div> 51 + 52 + <h2>3. Third-Party Data Processors</h2> 53 + <p>We only share your data with the following third-party processors:</p> 54 + 55 + <h3>Resend (Email Services)</h3> 56 + <ul> 57 + <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 + <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 + <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 + </ul> 61 + 62 + <h3>Cloudflare (Image Caching)</h3> 63 + <ul> 64 + <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 + <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 + <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 + </ul> 68 + 69 + <h2>4. How We Use Your Information</h2> 70 + <p>We use your information to:</p> 71 + <ul> 72 + <li>Provide and maintain the Service</li> 73 + <li>Process your transactions and requests</li> 74 + <li>Send you technical notices and support messages</li> 75 + <li>Improve and develop new features</li> 76 + <li>Ensure security and prevent fraud</li> 77 + <li>Comply with legal obligations</li> 78 + </ul> 79 + 80 + <h2>5. Data Sharing and Disclosure</h2> 81 + <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 + <ul> 83 + <li>With the third-party processors listed above</li> 84 + <li>When required by law or legal process</li> 85 + <li>To protect our rights, property, or safety, or that of our users</li> 86 + <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 + </ul> 88 + 89 + <h2>6. Data Security</h2> 90 + <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 + 92 + <h2>7. Data Retention</h2> 93 + <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 + 95 + <h2>8. Your Rights</h2> 96 + <p>Under applicable data protection laws, you have the right to:</p> 97 + <ul> 98 + <li>Access your personal information</li> 99 + <li>Correct inaccurate information</li> 100 + <li>Request deletion of your information</li> 101 + <li>Object to processing of your information</li> 102 + <li>Data portability</li> 103 + <li>Withdraw consent (where applicable)</li> 104 + </ul> 105 + 106 + <h2>9. Cookies and Tracking</h2> 107 + <p>We use cookies and similar technologies to:</p> 108 + <ul> 109 + <li>Maintain your login session</li> 110 + <li>Remember your preferences</li> 111 + <li>Analyze usage patterns to improve the Service</li> 112 + </ul> 113 + <p>You can control cookie settings through your browser preferences.</p> 114 + 115 + <h2>10. Children's Privacy</h2> 116 + <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 + 118 + <h2>11. International Data Transfers</h2> 119 + <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 + 121 + <h2>12. Changes to This Privacy Policy</h2> 122 + <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 + 124 + <h2>13. Contact Information</h2> 125 + <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 + 127 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 + <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 130 + </div> 131 + </div> 132 + </div> 133 + {{ end }}
+71
appview/pages/templates/legal/terms.html
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + <h1>Terms of Service</h1> 8 + 9 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 + 11 + <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 + 13 + <h2>1. Acceptance of Terms</h2> 14 + <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 + 16 + <h2>2. Account Registration</h2> 17 + <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 + 19 + <h2>3. Account Termination</h2> 20 + <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 + <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 + <p class="text-red-700 dark:text-red-300"> 23 + <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 + </p> 25 + <p class="text-red-700 dark:text-red-300 mt-2"> 26 + Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 + </p> 28 + </div> 29 + 30 + <h2>4. Acceptable Use</h2> 31 + <p>You agree not to use the Service to:</p> 32 + <ul> 33 + <li>Violate any applicable laws or regulations</li> 34 + <li>Infringe upon the rights of others</li> 35 + <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 + <li>Engage in spam, phishing, or other deceptive practices</li> 37 + <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 + <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 + </ul> 40 + 41 + <h2>5. Content and Intellectual Property</h2> 42 + <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 + 44 + <h2>6. Privacy</h2> 45 + <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 + 47 + <h2>7. Disclaimers</h2> 48 + <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 + 50 + <h2>8. Limitation of Liability</h2> 51 + <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 + 53 + <h2>9. Indemnification</h2> 54 + <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 + 56 + <h2>10. Governing Law</h2> 57 + <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 + 59 + <h2>11. Changes to Terms</h2> 60 + <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 + 62 + <h2>12. Contact Information</h2> 63 + <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 + 65 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 + <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 68 + </div> 69 + </div> 70 + </div> 71 + {{ end }}
+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
+27 -22
appview/pages/templates/repo/settings/pipelines.html
··· 20 20 <div class="col-span-1 md:col-span-2"> 21 21 <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 - Choose a spindle to execute your workflows on. Spindles can be 24 - selfhosted, 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 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 26 click to learn more. 27 27 </a> 28 28 </p> 29 29 </div> 30 - <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"> 31 - <select 32 - id="spindle" 33 - name="spindle" 34 - required 35 - class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 36 - {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 37 - <option value="" disabled selected > 38 - Choose a spindle 39 - </option> 40 - {{ range $.Spindles }} 41 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 42 - {{ . }} 30 + {{ if not $.RepoInfo.Roles.IsOwner }} 31 + <div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 32 + {{ or $.CurrentSpindle "No spindle configured" }} 33 + </div> 34 + {{ else }} 35 + <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 + <select 37 + id="spindle" 38 + name="spindle" 39 + required 40 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 + <option value="" disabled> 42 + Choose a spindle 43 43 </option> 44 - {{ end }} 45 - </select> 46 - <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 47 - {{ i "check" "size-4" }} 48 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 - </button> 50 - </form> 44 + {{ range $.Spindles }} 45 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 46 + {{ . }} 47 + </option> 48 + {{ end }} 49 + </select> 50 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 51 + {{ i "check" "size-4" }} 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </button> 54 + </form> 55 + {{ end }} 51 56 </div> 52 57 {{ end }} 53 58
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
+3 -2
appview/pages/templates/repo/tree.html
··· 61 61 62 62 {{ if .IsFile }} 63 63 {{ $icon = "file" }} 64 - {{ $iconStyle = "size-4" }} 64 + {{ $iconStyle = "flex-shrink-0 size-4" }} 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }}{{ .Name }} 68 + {{ i $icon $iconStyle }} 69 + <span class="truncate">{{ .Name }}</span> 69 70 </div> 70 71 </a> 71 72 </div>
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+89
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename with extension" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 + rows="20" 36 + placeholder="Paste your string here!" 37 + required>{{ .String.Contents }}</textarea> 38 + <div class="flex justify-between items-center"> 39 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 40 + <span id="line-count">0 lines</span> 41 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 42 + <span id="byte-count">0 bytes</span> 43 + </div> 44 + <div id="actions" class="flex gap-2 items-center"> 45 + {{ if eq .Action "edit" }} 46 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 47 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 48 + {{ i "x" "size-4" }} 49 + <span class="hidden md:inline">cancel</span> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </a> 52 + {{ end }} 53 + <button 54 + type="submit" 55 + id="new-button" 56 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 57 + > 58 + <span class="inline-flex items-center gap-2"> 59 + {{ i "arrow-up" "w-4 h-4" }} 60 + publish 61 + </span> 62 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 63 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 64 + </span> 65 + </button> 66 + </div> 67 + </div> 68 + <script> 69 + (function() { 70 + const textarea = document.getElementById('content-textarea'); 71 + const lineCount = document.getElementById('line-count'); 72 + const byteCount = document.getElementById('byte-count'); 73 + function updateStats() { 74 + const content = textarea.value; 75 + const lines = content === '' ? 0 : content.split('\n').length; 76 + const bytes = new TextEncoder().encode(content).length; 77 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 78 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 79 + } 80 + textarea.addEventListener('input', updateStats); 81 + textarea.addEventListener('paste', () => { 82 + setTimeout(updateStats, 0); 83 + }); 84 + updateStats(); 85 + })(); 86 + </script> 87 + <div id="error" class="error dark:text-red-400"></div> 88 + </form> 89 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+85
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span class="flex items-center"> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 + {{ end }} 52 + 53 + {{ with .String.Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 + {{ end }} 58 + </span> 59 + </section> 60 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 62 + <span>{{ .String.Filename }}</span> 63 + <div> 64 + <span>{{ .Stats.LineCount }} lines</span> 65 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 66 + <span>{{ byteFmt .Stats.ByteCount }}</span> 67 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 68 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 69 + {{ if .RenderToggle }} 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 72 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 73 + </a> 74 + {{ end }} 75 + </div> 76 + </div> 77 + <div class="overflow-auto relative"> 78 + {{ if .ShowRendered }} 79 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 + {{ else }} 81 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 82 + {{ end }} 83 + </div> 84 + </section> 85 + {{ end }}
+2 -2
appview/pages/templates/timeline.html
··· 34 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>
+6 -6
appview/pages/templates/user/completeSignup.html
··· 38 38 tightly-knit social coding. 39 39 </h2> 40 40 <form 41 - class="mt-4 max-w-sm mx-auto" 41 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 42 42 hx-post="/signup/complete" 43 43 hx-swap="none" 44 44 hx-disabled-elt="#complete-signup-button" ··· 51 51 name="code" 52 52 tabindex="1" 53 53 required 54 - placeholder="pds-tngl-sh-foo-bar" 54 + placeholder="tngl-sh-foo-bar" 55 55 /> 56 56 <span class="text-sm text-gray-500 mt-1"> 57 57 Enter the code sent to your email. 58 58 </span> 59 59 </div> 60 60 61 - <div class="flex flex-col mt-4"> 62 - <label for="username">desired username</label> 61 + <div class="flex flex-col"> 62 + <label for="username">username</label> 63 63 <input 64 64 type="text" 65 65 id="username" ··· 73 73 </span> 74 74 </div> 75 75 76 - <div class="flex flex-col mt-4"> 76 + <div class="flex flex-col"> 77 77 <label for="password">password</label> 78 78 <input 79 79 type="password" ··· 88 88 </div> 89 89 90 90 <button 91 - class="btn-create w-full my-2 mt-6" 91 + class="btn-create w-full my-2 mt-6 text-base" 92 92 type="submit" 93 93 id="complete-signup-button" 94 94 tabindex="4"
+1 -3
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 10 <p title="{{ didOrHandle .UserDid .UserHandle }}"
+11 -79
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 or sign up for 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 - /> 28 - <title>login or sign up &middot; tangled</title> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 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 54 - placeholder="foo.tngl.sh" 36 + placeholder="akshay.tngl.sh" 55 37 /> 56 38 <span class="text-sm text-gray-500 mt-1"> 57 39 Use your <a href="https://atproto.com">ATProto</a> ··· 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" ··· 69 51 <span>login</span> 70 52 </button> 71 53 </form> 72 - <hr class="my-4"> 73 - <p class="text-sm text-gray-500 mt-4"> 74 - Alternatively, you may create an account on Tangled below. You will 75 - get a <code>user.tngl.sh</code> handle. 54 + <p class="text-sm text-gray-500"> 55 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 76 56 </p> 77 57 78 - <details class="group"> 79 - 80 - <summary 81 - class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2" 82 - > 83 - create an account 84 - 85 - <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div> 86 - <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div> 87 - </summary> 88 - <form 89 - class="mt-4 max-w-sm mx-auto" 90 - hx-post="/signup" 91 - hx-swap="none" 92 - hx-disabled-elt="#signup-button" 93 - > 94 - <div class="flex flex-col mt-2"> 95 - <label for="email">email</label> 96 - <input 97 - type="email" 98 - id="email" 99 - name="email" 100 - tabindex="4" 101 - required 102 - placeholder="jason@bourne.co" 103 - /> 104 - </div> 105 - <span class="text-sm text-gray-500 mt-1"> 106 - You will receive an email with a code. Enter that, along with your 107 - desired username and password in the next page to complete your registration. 108 - </span> 109 - <button 110 - class="btn w-full my-2 mt-6" 111 - type="submit" 112 - id="signup-button" 113 - tabindex="7" 114 - > 115 - <span>sign up</span> 116 - </button> 117 - </form> 118 - </details> 119 - <p class="text-sm text-gray-500 mt-6"> 120 - Join our <a href="https://chat.tangled.sh">Discord</a> or 121 - IRC channel: 122 - <a href="https://web.libera.chat/#tangled" 123 - ><code>#tangled</code> on Libera Chat</a 124 - >. 125 - </p> 126 58 <p id="login-msg" class="error w-full"></p> 127 59 </main> 128 60 </body>
+53
appview/pages/templates/user/signup.html
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+44 -5
appview/repo/repo.go
··· 39 39 "github.com/go-git/go-git/v5/plumbing" 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 ··· 741 742 return 742 743 } 743 744 745 + // remove a single leading `@`, to make @handle work with ResolveIdent 746 + collaborator = strings.TrimPrefix(collaborator, "@") 747 + 744 748 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 745 749 if err != nil { 746 750 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) ··· 751 755 fail("You seem to be adding yourself as a collaborator.", nil) 752 756 return 753 757 } 754 - 755 758 l = l.With("collaborator", collaboratorIdent.Handle) 756 759 l = l.With("knot", f.Knot) 757 - l.Info("adding to knot") 758 760 761 + // announce this relation into the firehose, store into owners' pds 762 + client, err := rp.oauth.AuthorizedClient(r) 763 + if err != nil { 764 + fail("Failed to write to PDS.", err) 765 + return 766 + } 767 + 768 + // emit a record 769 + currentUser := rp.oauth.GetUser(r) 770 + rkey := tid.TID() 771 + createdAt := time.Now() 772 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 773 + Collection: tangled.RepoCollaboratorNSID, 774 + Repo: currentUser.Did, 775 + Rkey: rkey, 776 + Record: &lexutil.LexiconTypeDecoder{ 777 + Val: &tangled.RepoCollaborator{ 778 + Subject: collaboratorIdent.DID.String(), 779 + Repo: string(f.RepoAt), 780 + CreatedAt: createdAt.Format(time.RFC3339), 781 + }}, 782 + }) 783 + // invalid record 784 + if err != nil { 785 + fail("Failed to write record to PDS.", err) 786 + return 787 + } 788 + l = l.With("at-uri", resp.Uri) 789 + l.Info("wrote record to PDS") 790 + 791 + l.Info("adding to knot") 759 792 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 760 793 if err != nil { 761 794 fail("Failed to add to knot.", err) ··· 798 831 return 799 832 } 800 833 801 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 834 + err = db.AddCollaborator(rp.db, db.Collaborator{ 835 + Did: syntax.DID(currentUser.Did), 836 + Rkey: rkey, 837 + SubjectDid: collaboratorIdent.DID, 838 + RepoAt: f.RepoAt, 839 + Created: createdAt, 840 + }) 802 841 if err != nil { 803 842 fail("Failed to add collaborator.", err) 804 843 return ··· 1189 1228 f, err := rp.repoResolver.Resolve(r) 1190 1229 user := rp.oauth.GetUser(r) 1191 1230 1192 - // all spindles that this user is a member of 1193 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1231 + // all spindles that the repo owner is a member of 1232 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1194 1233 if err != nil { 1195 1234 log.Println("failed to fetch spindles", err) 1196 1235 return
+130 -65
appview/signup/signup.go
··· 1 1 package signup 2 2 3 3 import ( 4 + "bufio" 4 5 "fmt" 5 6 "log/slog" 6 7 "net/http" 8 + "os" 9 + "strings" 7 10 8 11 "github.com/go-chi/chi/v5" 9 12 "github.com/posthog/posthog-go" ··· 18 21 ) 19 22 20 23 type Signup struct { 21 - config *config.Config 22 - db *db.DB 23 - cf *dns.Cloudflare 24 - posthog posthog.Client 25 - xrpc *xrpcclient.Client 26 - idResolver *idresolver.Resolver 27 - pages *pages.Pages 28 - l *slog.Logger 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 29 33 } 30 34 31 35 func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { ··· 38 42 } 39 43 } 40 44 45 + disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + 41 47 return &Signup{ 42 - config: cfg, 43 - db: database, 44 - posthog: pc, 45 - idResolver: idResolver, 46 - cf: cf, 47 - pages: pages, 48 - l: l, 48 + config: cfg, 49 + db: database, 50 + posthog: pc, 51 + idResolver: idResolver, 52 + cf: cf, 53 + pages: pages, 54 + l: l, 55 + disallowedNicknames: disallowedNicknames, 49 56 } 50 57 } 51 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 + 52 105 func (s *Signup) Router() http.Handler { 53 106 r := chi.NewRouter() 107 + r.Get("/", s.signup) 54 108 r.Post("/", s.signup) 55 109 r.Get("/complete", s.complete) 56 110 r.Post("/complete", s.complete) ··· 59 113 } 60 114 61 115 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 62 - if s.cf == nil { 63 - http.Error(w, "signup is disabled", http.StatusFailedDependency) 64 - } 65 - emailId := r.FormValue("email") 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") 66 124 67 - if !email.IsValidEmail(emailId) { 68 - s.pages.Notice(w, "login-msg", "Invalid email address.") 69 - return 70 - } 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 71 130 72 - exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 73 - if err != nil { 74 - s.l.Error("failed to check email existence", "error", err) 75 - s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 76 - return 77 - } 78 - if exists { 79 - s.pages.Notice(w, "login-msg", "Email already exists.") 80 - return 81 - } 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 + } 82 141 83 - code, err := s.inviteCodeRequest() 84 - if err != nil { 85 - s.l.Error("failed to create invite code", "error", err) 86 - s.pages.Notice(w, "login-msg", "Failed to create invite code.") 87 - return 88 - } 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 + } 89 148 90 - em := email.Email{ 91 - APIKey: s.config.Resend.ApiKey, 92 - From: s.config.Resend.SentFrom, 93 - To: emailId, 94 - Subject: "Verify your Tangled account", 95 - Text: `Copy and paste this code below to verify your account on Tangled. 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. 96 155 ` + code, 97 - Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 98 157 <p><code>` + code + `</code></p>`, 99 - } 158 + } 100 159 101 - err = email.SendEmail(em) 102 - if err != nil { 103 - s.l.Error("failed to send email", "error", err) 104 - s.pages.Notice(w, "login-msg", "Failed to send email.") 105 - return 106 - } 107 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 108 - Email: emailId, 109 - InviteCode: code, 110 - }) 111 - if err != nil { 112 - s.l.Error("failed to add inflight signup", "error", err) 113 - s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 114 - return 115 - } 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 + } 116 175 117 - s.pages.HxRedirect(w, "/signup/complete") 176 + s.pages.HxRedirect(w, "/signup/complete") 177 + } 118 178 } 119 179 120 180 func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 121 181 switch r.Method { 122 182 case http.MethodGet: 123 - s.pages.CompleteSignup(w, pages.SignupParams{}) 183 + s.pages.CompleteSignup(w) 124 184 case http.MethodPost: 125 185 username := r.FormValue("username") 126 186 password := r.FormValue("password") ··· 128 188 129 189 if !userutil.IsValidSubdomain(username) { 130 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.") 131 196 return 132 197 } 133 198 ··· 154 219 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 155 220 Type: "TXT", 156 221 Name: "_atproto." + username, 157 - Content: "did=" + did, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 158 223 TTL: 6400, 159 224 Proxied: false, 160 225 })
+4 -4
appview/spindles/spindles.go
··· 619 619 620 620 if string(spindles[0].Owner) != user.Did { 621 621 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 622 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 622 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 623 623 return 624 624 } 625 625 626 626 member := r.FormValue("member") 627 627 if member == "" { 628 628 l.Error("empty member") 629 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 629 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 630 630 return 631 631 } 632 632 l = l.With("member", member) ··· 634 634 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 635 635 if err != nil { 636 636 l.Error("failed to resolve member identity to handle", "err", err) 637 - 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.") 638 638 return 639 639 } 640 640 if memberId.Handle.IsInvalidHandle() { 641 641 l.Error("failed to resolve member identity to handle") 642 - 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.") 643 643 return 644 644 } 645 645
-16
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 142 139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 143 140 } 144 141 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 142 s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 143 LoggedInUser: loggedInUser, 148 144 Repos: pinnedRepos, ··· 151 147 Card: pages.ProfileCard{ 152 148 UserDid: ident.DID.String(), 153 149 UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 150 Profile: profile, 156 151 FollowStatus: followStatus, 157 152 Followers: followers, ··· 194 189 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 195 190 } 196 191 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 198 - 199 192 s.pages.ReposPage(w, pages.ReposPageParams{ 200 193 LoggedInUser: loggedInUser, 201 194 Repos: repos, ··· 203 196 Card: pages.ProfileCard{ 204 197 UserDid: ident.DID.String(), 205 198 UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 199 Profile: profile, 208 200 FollowStatus: followStatus, 209 201 Followers: followers, 210 202 Following: following, 211 203 }, 212 204 }) 213 - } 214 - 215 - func (s *State) GetAvatarUri(handle string) string { 216 - secret := s.config.Avatar.SharedSecret 217 - h := hmac.New(sha256.New, []byte(secret)) 218 - h.Write([]byte(handle)) 219 - signature := hex.EncodeToString(h.Sum(nil)) 220 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 221 205 } 222 206 223 207 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+21 -3
appview/state/router.go
··· 17 17 "tangled.sh/tangled.sh/core/appview/signup" 18 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 20 21 "tangled.sh/tangled.sh/core/log" 21 22 ) 22 23 ··· 66 67 67 68 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 68 69 r := chi.NewRouter() 69 - 70 - // strip @ from user 71 - r.Use(middleware.StripLeadingAt) 72 70 73 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 74 72 r.Get("/", s.Profile) ··· 136 134 }) 137 135 138 136 r.Mount("/settings", s.SettingsRouter()) 137 + r.Mount("/strings", s.StringsRouter(mw)) 139 138 r.Mount("/knots", s.KnotsRouter(mw)) 140 139 r.Mount("/spindles", s.SpindlesRouter()) 141 140 r.Mount("/signup", s.SignupRouter()) 142 141 r.Mount("/", s.OAuthRouter()) 143 142 144 143 r.Get("/keys/{user}", s.Keys) 144 + r.Get("/terms", s.TermsOfService) 145 + r.Get("/privacy", s.PrivacyPolicy) 145 146 146 147 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 147 148 s.pages.Error404(w) ··· 197 198 } 198 199 199 200 return knots.Router(mw) 201 + } 202 + 203 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 204 + logger := log.New("strings") 205 + 206 + strs := &avstrings.Strings{ 207 + Db: s.db, 208 + OAuth: s.oauth, 209 + Pages: s.pages, 210 + Config: s.config, 211 + Enforcer: s.enforcer, 212 + IdResolver: s.idResolver, 213 + Knotstream: s.knotstream, 214 + Logger: logger, 215 + } 216 + 217 + return strs.Router(mw) 200 218 } 201 219 202 220 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+15
appview/state/state.go
··· 93 93 tangled.ActorProfileNSID, 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 96 97 }, 97 98 nil, 98 99 slog.Default(), ··· 154 155 } 155 156 156 157 return state, nil 158 + } 159 + 160 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 161 + user := s.oauth.GetUser(r) 162 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 163 + LoggedInUser: user, 164 + }) 165 + } 166 + 167 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 168 + user := s.oauth.GetUser(r) 169 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 170 + LoggedInUser: user, 171 + }) 157 172 } 158 173 159 174 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
+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 + }
+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 }
+9 -10
docs/hacking.md
··· 56 56 `nixosConfiguration` to do so. 57 57 58 58 To begin, head to `http://localhost:3000/knots` in the browser 59 - and generate a knot secret. Replace the existing secret in 60 - `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 - secret. 59 + and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it, 60 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 61 + don't lose it. 62 62 63 63 You can now start a lightweight NixOS VM using 64 64 `nixos-shell` like so: ··· 91 91 92 92 ## running a spindle 93 93 94 - Be sure to change the `owner` field for the spindle in 95 - `nix/vm.nix` to your own DID. The above VM should already 96 - be running a spindle on `localhost:6555`. You can head to 97 - the spindle dashboard on `http://localhost:3000/spindles`, 98 - and register a spindle with hostname `localhost:6555`. It 99 - should instantly be verified. You can then configure each 100 - repository to use this spindle and run CI jobs. 94 + Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID. 95 + The above VM should already be running a spindle on `localhost:6555`. 96 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 97 + and register a spindle with hostname `localhost:6555`. It should instantly 98 + be verified. You can then configure each repository to use this spindle 99 + and run CI jobs. 101 100 102 101 Of interest when debugging spindles: 103 102
+1 -1
docs/knot-hosting.md
··· 89 89 systemctl start knotserver 90 90 ``` 91 91 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 92 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 93 93 knot. Here's an example configuration for Nginx: 94 94 95 95 ```
+193 -38
docs/spindle/openbao.md
··· 1 1 # spindle secrets with openbao 2 2 3 3 This document covers setting up Spindle to use OpenBao for secrets 4 - management instead of the default SQLite backend. 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. 5 14 6 15 ## installation 7 16 8 17 Install OpenBao from nixpkgs: 9 18 10 19 ```bash 11 - nix-env -iA nixpkgs.openbao 20 + nix shell nixpkgs#openbao # for a local server 12 21 ``` 13 22 14 - ## local development setup 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 15 28 16 29 Start OpenBao in dev mode: 17 30 18 31 ```bash 19 - bao server -dev 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 20 33 ``` 21 34 22 - This starts OpenBao on `http://localhost:8200` with a root token. Save 23 - the root token from the output -- you'll need it. 35 + This starts OpenBao on `http://localhost:8201` with a root token. 24 36 25 37 Set up environment for bao CLI: 26 38 27 39 ```bash 28 40 export BAO_ADDR=http://localhost:8200 29 - export BAO_TOKEN=hvs.your-root-token-here 41 + export BAO_TOKEN=root 30 42 ``` 31 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 + 32 64 Create the spindle KV mount: 33 65 34 66 ```bash 35 67 bao secrets enable -path=spindle -version=2 kv 36 68 ``` 37 69 38 - Set up AppRole authentication: 70 + Set up AppRole authentication and policy: 39 71 40 72 Create a policy file `spindle-policy.hcl`: 41 73 42 74 ```hcl 75 + # Full access to spindle KV v2 data 43 76 path "spindle/data/*" { 44 - capabilities = ["create", "read", "update", "delete", "list"] 77 + capabilities = ["create", "read", "update", "delete"] 45 78 } 46 79 80 + # Access to metadata for listing and management 47 81 path "spindle/metadata/*" { 48 - capabilities = ["list", "read", "delete"] 82 + capabilities = ["list", "read", "delete", "update"] 49 83 } 50 84 51 - path "spindle/*" { 85 + # Allow listing at root level 86 + path "spindle/" { 52 87 capabilities = ["list"] 53 88 } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 54 94 ``` 55 95 56 96 Apply the policy and create an AppRole: ··· 61 101 bao write auth/approle/role/spindle \ 62 102 token_policies="spindle-policy" \ 63 103 token_ttl=1h \ 64 - token_max_ttl=4h 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 65 108 ``` 66 109 67 110 Get the credentials: 68 111 69 112 ```bash 70 - bao read auth/approle/role/spindle/role-id 71 - bao write -f auth/approle/role/spindle/secret-id 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" 72 182 ``` 73 183 74 - Configure Spindle: 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 75 196 76 197 Set these environment variables for Spindle: 77 198 78 199 ```bash 79 200 export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 80 - export SPINDLE_SERVER_SECRETS_OPENBAO_ADDR=http://localhost:8200 81 - export SPINDLE_SERVER_SECRETS_OPENBAO_ROLE_ID=your-role-id-from-above 82 - export SPINDLE_SERVER_SECRETS_OPENBAO_SECRET_ID=your-secret-id-from-above 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 83 202 export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 84 203 ``` 85 204 86 205 Start Spindle: 87 206 88 - Spindle will now use OpenBao for secrets storage with automatic token 89 - renewal. 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. 90 216 91 217 ## verifying setup 92 218 93 - List all secrets: 219 + Test the proxy directly: 94 220 95 221 ```bash 96 - bao kv list spindle/ 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 97 227 ``` 98 228 99 - Add a test secret via Spindle API, then check it exists: 229 + Test OpenBao operations through the server: 100 230 101 231 ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 102 236 bao kv list spindle/repos/ 103 - ``` 104 237 105 - Get a specific secret: 106 - 107 - ```bash 238 + # Get a specific secret 108 239 bao kv get spindle/repos/your_repo_path/SECRET_NAME 109 240 ``` 110 241 111 242 ## how it works 112 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 113 247 - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 114 - - Each repository gets its own namespace 115 - - Repository paths like `at://did:plc:alice/myrepo` become 116 - `at_did_plc_alice_myrepo` 117 - - The system automatically handles token renewal using AppRole 118 - authentication 119 - - On shutdown, Spindle cleanly stops the token renewal process 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 120 251 121 252 ## troubleshooting 122 253 123 - **403 errors**: Check that your BAO_TOKEN is set and the spindle mount 124 - exists 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. 125 259 126 260 **404 route errors**: The spindle KV mount probably doesn't exist - run 127 - the mount creation step again 261 + the mount creation step again. 128 262 129 - **Token expired**: The AppRole system should handle this automatically, 130 - but you can check token status with `bao token lookup` 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 -28
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" ··· 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",
+74 -26
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 234 nixosModules.appview = { ··· 217 258 218 259 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 260 }; 220 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 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 + }; 221 269 }; 222 270 }
+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 {
-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 }
+61 -1
knotserver/ingester.go
··· 213 213 return h.db.InsertEvent(event, h.n) 214 214 } 215 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 + 216 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 217 267 l := log.FromContext(ctx) 218 268 ··· 266 316 defer func() { 267 317 eventTime := event.TimeUS 268 318 lastTimeUs := eventTime + 1 269 - fmt.Println("lastTimeUs", lastTimeUs) 270 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 271 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 272 321 } ··· 292 341 if err := h.processKnotMember(ctx, did, record); err != nil { 293 342 return fmt.Errorf("failed to process knot member: %w", err) 294 343 } 344 + 295 345 case tangled.RepoPullNSID: 296 346 var record tangled.RepoPull 297 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 300 350 if err := h.processPull(ctx, did, record); err != nil { 301 351 return fmt.Errorf("failed to process knot member: %w", err) 302 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 + 303 363 } 304 364 305 365 return err
+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)
-37
lexicons/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/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 - }
-29
lexicons/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/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 - }
+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 - }
-31
lexicons/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 - }
+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 + }
+6
nix/gomod2nix.toml
··· 66 66 [mod."github.com/cloudflare/circl"] 67 67 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 68 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 69 72 [mod."github.com/containerd/errdefs"] 70 73 version = "v1.0.0" 71 74 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 169 172 [mod."github.com/golang/mock"] 170 173 version = "v1.6.0" 171 174 hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 172 178 [mod."github.com/google/uuid"] 173 179 version = "v1.6.0" 174 180 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
+22
nix/modules/spindle.nix
··· 54 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 55 description = "DID of owner (required)"; 56 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 + }; 57 76 }; 58 77 59 78 pipelines = { ··· 89 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 90 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 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}" 92 114 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 115 "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 116 ];
+23
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 + mkdir -p $out/{fonts,icons} && cd $out 14 + cp -f ${htmx-src} htmx.min.js 15 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 16 + cp -rf ${lucide-src}/*.svg icons/ 17 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 18 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 19 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/ 20 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 21 + # for whatever reason (produces broken css), so we are doing this instead 22 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 23 + ''
+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
+2 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - gitignoreSource, 2 + src, 3 3 buildGoApplication, 4 4 modules, 5 5 }: 6 6 buildGoApplication { 7 7 pname = "genjwks"; 8 8 version = "0.1.0"; 9 - src = gitignoreSource ../..; 10 - inherit modules; 9 + inherit src modules; 11 10 subPackages = ["cmd/genjwks"]; 12 11 doCheck = false; 13 12 CGO_ENABLED = 0;
+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 -63
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 - motd = "Welcome to the development knot!\n"; 52 - server = { 53 - secretFile = "/var/lib/knot/secret"; 54 - hostname = "localhost:6000"; 55 - 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 + }; 56 72 }; 57 - }; 58 - services.tangled-spindle = { 59 - enable = true; 60 - server = { 61 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 62 - hostname = "localhost:6555"; 63 - listenAddr = "0.0.0.0:6555"; 64 - 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 + }; 65 84 }; 66 - }; 67 - }) 68 - ]; 69 - } 85 + }) 86 + ]; 87 + }
+2 -4
spindle/config/config.go
··· 28 28 } 29 29 30 30 type OpenBaoConfig struct { 31 - Addr string `env:"ADDR"` 32 - RoleID string `env:"ROLE_ID"` 33 - SecretID string `env:"SECRET_ID"` 34 - Mount string `env:"MOUNT, default=spindle"` 31 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 32 + Mount string `env:"MOUNT, default=spindle"` 35 33 } 36 34 37 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 + }
+161 -7
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 - "path/filepath" 8 + "time" 8 9 9 10 "tangled.sh/tangled.sh/core/api/tangled" 10 11 "tangled.sh/tangled.sh/core/eventconsumer" 12 + "tangled.sh/tangled.sh/core/idresolver" 11 13 "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 12 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" 13 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 14 22 ) 15 23 16 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 35 43 s.ingestMember(ctx, e) 36 44 case tangled.RepoNSID: 37 45 s.ingestRepo(ctx, e) 46 + case tangled.RepoCollaboratorNSID: 47 + s.ingestCollaborator(ctx, e) 38 48 } 39 49 40 50 return err ··· 42 52 } 43 53 44 54 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 55 + var err error 45 56 did := e.Did 46 - var err error 57 + rkey := e.Commit.RKey 47 58 48 59 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 49 60 ··· 58 69 } 59 70 60 71 domain := s.cfg.Server.Hostname 61 - if s.cfg.Server.Dev { 62 - domain = s.cfg.Server.ListenAddr 63 - } 64 72 recordInstance := record.Instance 65 73 66 74 if recordInstance != domain { ··· 74 82 return fmt.Errorf("failed to enforce permissions: %w", err) 75 83 } 76 84 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 + 77 96 if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 78 97 l.Error("failed to add member", "error", err) 79 98 return fmt.Errorf("failed to add member: %w", err) ··· 88 107 89 108 return nil 90 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 + 91 134 } 92 135 return nil 93 136 } 94 137 95 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 138 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 96 139 var err error 140 + did := e.Did 141 + resolver := idresolver.DefaultResolver() 97 142 98 143 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 99 144 ··· 129 174 return fmt.Errorf("failed to add repo: %w", err) 130 175 } 131 176 177 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 178 + if err != nil { 179 + return err 180 + } 181 + 132 182 // add repo to rbac 133 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil { 183 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 134 184 l.Error("failed to add repo to enforcer", "error", err) 135 185 return fmt.Errorf("failed to add repo: %w", err) 136 186 } 137 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 + 138 197 // add this knot to the event consumer 139 198 src := eventconsumer.NewKnotSource(record.Knot) 140 199 s.ks.AddSource(context.Background(), src) ··· 144 203 } 145 204 return nil 146 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 + }
+56 -150
spindle/secrets/openbao.go
··· 6 6 "log/slog" 7 7 "path" 8 8 "strings" 9 - "sync" 10 9 "time" 11 10 12 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 16 15 type OpenBaoManager struct { 17 16 client *vault.Client 18 17 mountPath string 19 - roleID string 20 - secretID string 21 - stopCh chan struct{} 22 - tokenMu sync.RWMutex 23 18 logger *slog.Logger 24 19 } 25 20 ··· 31 26 } 32 27 } 33 28 34 - func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 35 - if address == "" { 36 - return nil, fmt.Errorf("address cannot be empty") 37 - } 38 - if roleID == "" { 39 - return nil, fmt.Errorf("role_id cannot be empty") 40 - } 41 - if secretID == "" { 42 - return nil, fmt.Errorf("secret_id cannot be empty") 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") 43 35 } 44 36 45 37 config := vault.DefaultConfig() 46 - config.Address = address 38 + config.Address = proxyAddress 47 39 48 40 client, err := vault.NewClient(config) 49 41 if err != nil { 50 42 return nil, fmt.Errorf("failed to create openbao client: %w", err) 51 43 } 52 44 53 - // Authenticate using AppRole 54 - err = authenticateAppRole(client, roleID, secretID) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err) 57 - } 58 - 59 45 manager := &OpenBaoManager{ 60 46 client: client, 61 47 mountPath: "spindle", // default KV v2 mount path 62 - roleID: roleID, 63 - secretID: secretID, 64 - stopCh: make(chan struct{}), 65 48 logger: logger, 66 49 } 67 50 ··· 69 52 opt(manager) 70 53 } 71 54 72 - go manager.tokenRenewalLoop() 73 - 74 - return manager, nil 75 - } 76 - 77 - // authenticateAppRole authenticates the client using AppRole method 78 - func authenticateAppRole(client *vault.Client, roleID, secretID string) error { 79 - authData := map[string]interface{}{ 80 - "role_id": roleID, 81 - "secret_id": secretID, 82 - } 83 - 84 - resp, err := client.Logical().Write("auth/approle/login", authData) 85 - if err != nil { 86 - return fmt.Errorf("failed to login with AppRole: %w", err) 87 - } 88 - 89 - if resp == nil || resp.Auth == nil { 90 - return fmt.Errorf("no auth info returned from AppRole login") 91 - } 92 - 93 - client.SetToken(resp.Auth.ClientToken) 94 - return nil 95 - } 96 - 97 - // stop stops the token renewal goroutine 98 - func (v *OpenBaoManager) Stop() { 99 - close(v.stopCh) 100 - } 101 - 102 - // tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens 103 - func (v *OpenBaoManager) tokenRenewalLoop() { 104 - ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds 105 - defer ticker.Stop() 106 - 107 - for { 108 - select { 109 - case <-v.stopCh: 110 - return 111 - case <-ticker.C: 112 - ctx := context.Background() 113 - if err := v.ensureValidToken(ctx); err != nil { 114 - v.logger.Error("openbao token renewal failed", "error", err) 115 - } 116 - } 117 - } 118 - } 119 - 120 - // ensureValidToken checks if the current token is valid and renews or re-authenticates if needed 121 - func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error { 122 - v.tokenMu.Lock() 123 - defer v.tokenMu.Unlock() 124 - 125 - // check current token info 126 - tokenInfo, err := v.client.Auth().Token().LookupSelf() 127 - if err != nil { 128 - // token is invalid, need to re-authenticate 129 - v.logger.Warn("token lookup failed, re-authenticating", "error", err) 130 - return v.reAuthenticate() 131 - } 132 - 133 - if tokenInfo == nil || tokenInfo.Data == nil { 134 - return v.reAuthenticate() 135 - } 136 - 137 - // check TTL 138 - ttlRaw, ok := tokenInfo.Data["ttl"] 139 - if !ok { 140 - return v.reAuthenticate() 141 - } 142 - 143 - var ttl int64 144 - switch t := ttlRaw.(type) { 145 - case int64: 146 - ttl = t 147 - case float64: 148 - ttl = int64(t) 149 - case int: 150 - ttl = int64(t) 151 - default: 152 - return v.reAuthenticate() 153 - } 154 - 155 - // if TTL is less than 5 minutes, try to renew 156 - if ttl < 300 { 157 - v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl) 158 - 159 - renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h 160 - if err != nil { 161 - v.logger.Warn("token renewal failed, re-authenticating", "error", err) 162 - return v.reAuthenticate() 163 - } 164 - 165 - if renewResp == nil || renewResp.Auth == nil { 166 - v.logger.Warn("token renewal returned no auth info, re-authenticating") 167 - return v.reAuthenticate() 168 - } 169 - 170 - v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration) 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 171 57 } 172 58 173 - return nil 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 174 61 } 175 62 176 - // reAuthenticate performs a fresh authentication using AppRole 177 - func (v *OpenBaoManager) reAuthenticate() error { 178 - v.logger.Info("re-authenticating with approle") 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() 179 67 180 - err := authenticateAppRole(v.client, v.roleID, v.secretID) 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) 181 71 if err != nil { 182 - return fmt.Errorf("re-authentication failed: %w", err) 72 + return fmt.Errorf("proxy connection test failed: %w", err) 183 73 } 184 74 185 - v.logger.Info("re-authentication successful") 186 75 return nil 187 76 } 188 77 189 78 func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 190 - v.tokenMu.RLock() 191 - defer v.tokenMu.RUnlock() 192 79 if err := ValidateKey(secret.Key); err != nil { 193 80 return err 194 81 } 195 82 196 83 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 197 - 198 - fmt.Println(v.mountPath, secretPath) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 199 85 86 + // Check if secret already exists 200 87 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 201 88 if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 202 90 return ErrKeyAlreadyPresent 203 91 } 204 92 ··· 210 98 "created_by": secret.CreatedBy.String(), 211 99 } 212 100 213 - _, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 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) 214 103 if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 215 105 return fmt.Errorf("failed to store secret in openbao: %w", err) 216 106 } 217 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) 218 123 return nil 219 124 } 220 125 221 126 func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 222 - v.tokenMu.RLock() 223 - defer v.tokenMu.RUnlock() 224 127 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 225 128 129 + // check if secret exists 226 130 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 227 131 if err != nil || existing == nil { 228 132 return ErrKeyNotFound 229 133 } 230 134 231 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 232 136 if err != nil { 233 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 234 138 } 235 139 140 + v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 236 141 return nil 237 142 } 238 143 239 144 func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 240 - v.tokenMu.RLock() 241 - defer v.tokenMu.RUnlock() 242 145 repoPath := v.buildRepoPath(repo) 243 146 244 - secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 147 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 245 148 if err != nil { 246 149 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 247 150 return []LockedSecret{}, nil ··· 266 169 continue 267 170 } 268 171 269 - secretPath := path.Join(repoPath, key) 172 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 270 173 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 271 174 if err != nil { 272 - continue // Skip secrets we can't read 175 + v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 + continue 273 177 } 274 178 275 179 if secretData == nil || secretData.Data == nil { ··· 308 212 secrets = append(secrets, secret) 309 213 } 310 214 215 + v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 311 216 return secrets, nil 312 217 } 313 218 314 219 func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 315 - v.tokenMu.RLock() 316 - defer v.tokenMu.RUnlock() 317 220 repoPath := v.buildRepoPath(repo) 318 221 319 - secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 222 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 320 223 if err != nil { 321 224 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 322 225 return []UnlockedSecret{}, nil ··· 341 244 continue 342 245 } 343 246 344 - secretPath := path.Join(repoPath, key) 247 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 345 248 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 346 249 if err != nil { 250 + v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 347 251 continue 348 252 } 349 253 ··· 355 259 356 260 valueStr, ok := data["value"].(string) 357 261 if !ok { 358 - continue // skip secrets without values 262 + v.logger.Warn("secret missing value", "path", secretPath) 263 + continue 359 264 } 360 265 361 266 createdAtStr, ok := data["created_at"].(string) ··· 389 294 secrets = append(secrets, secret) 390 295 } 391 296 297 + v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 392 298 return secrets, nil 393 299 } 394 300 395 - // buildRepoPath creates an OpenBao path for a repository 301 + // buildRepoPath creates a safe path for a repository 396 302 func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 397 303 // convert DidSlashRepo to a safe path by replacing special characters 398 304 repoPath := strings.ReplaceAll(string(repo), "/", "_") ··· 401 307 return fmt.Sprintf("repos/%s", repoPath) 402 308 } 403 309 404 - // buildSecretPath creates an OpenBao path for a specific secret 310 + // buildSecretPath creates a path for a specific secret 405 311 func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 406 312 return path.Join(v.buildRepoPath(repo), key) 407 313 }
+59 -84
spindle/secrets/openbao_test.go
··· 16 16 secrets map[string]UnlockedSecret // key: repo_key format 17 17 shouldError bool 18 18 errorToReturn error 19 - stopped bool 20 19 } 21 20 22 21 func NewMockOpenBaoManager() *MockOpenBaoManager { ··· 31 30 func (m *MockOpenBaoManager) ClearError() { 32 31 m.shouldError = false 33 32 m.errorToReturn = nil 34 - } 35 - 36 - func (m *MockOpenBaoManager) Stop() { 37 - m.stopped = true 38 - } 39 - 40 - func (m *MockOpenBaoManager) IsStopped() bool { 41 - return m.stopped 42 33 } 43 34 44 35 func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { ··· 118 109 } 119 110 } 120 111 112 + // Test MockOpenBaoManager interface compliance 113 + func TestMockOpenBaoManagerInterface(t *testing.T) { 114 + var _ Manager = (*MockOpenBaoManager)(nil) 115 + } 116 + 121 117 func TestOpenBaoManagerInterface(t *testing.T) { 122 118 var _ Manager = (*OpenBaoManager)(nil) 123 119 } ··· 125 121 func TestNewOpenBaoManager(t *testing.T) { 126 122 tests := []struct { 127 123 name string 128 - address string 129 - roleID string 130 - secretID string 124 + proxyAddr string 131 125 opts []OpenBaoManagerOpt 132 126 expectError bool 133 127 errorContains string 134 128 }{ 135 129 { 136 - name: "empty address", 137 - address: "", 138 - roleID: "test-role-id", 139 - secretID: "test-secret-id", 130 + name: "empty proxy address", 131 + proxyAddr: "", 140 132 opts: nil, 141 133 expectError: true, 142 - errorContains: "address cannot be empty", 134 + errorContains: "proxy address cannot be empty", 143 135 }, 144 136 { 145 - name: "empty role_id", 146 - address: "http://localhost:8200", 147 - roleID: "", 148 - secretID: "test-secret-id", 137 + name: "valid proxy address", 138 + proxyAddr: "http://localhost:8200", 149 139 opts: nil, 150 - expectError: true, 151 - errorContains: "role_id cannot be empty", 140 + expectError: true, // Will fail because no real proxy is running 141 + errorContains: "failed to connect to bao proxy", 152 142 }, 153 143 { 154 - name: "empty secret_id", 155 - address: "http://localhost:8200", 156 - roleID: "test-role-id", 157 - secretID: "", 158 - opts: nil, 159 - expectError: true, 160 - errorContains: "secret_id cannot be empty", 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", 161 149 }, 162 150 } 163 151 164 152 for _, tt := range tests { 165 153 t.Run(tt.name, func(t *testing.T) { 166 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 167 - manager, err := NewOpenBaoManager(tt.address, tt.roleID, tt.secretID, logger, tt.opts...) 155 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 168 156 169 157 if tt.expectError { 170 158 assert.Error(t, err) 171 159 assert.Nil(t, manager) 172 160 assert.Contains(t, err.Error(), tt.errorContains) 173 161 } else { 174 - // For valid configurations, we expect an error during authentication 175 - // since we're not connecting to a real OpenBao server 176 - assert.Error(t, err) 177 - assert.Nil(t, manager) 162 + assert.NoError(t, err) 163 + assert.NotNil(t, manager) 178 164 } 179 165 }) 180 166 } ··· 253 239 assert.Equal(t, "custom-mount", manager.mountPath) 254 240 } 255 241 256 - func TestOpenBaoManager_Stop(t *testing.T) { 257 - // Create a manager with minimal setup 258 - manager := &OpenBaoManager{ 259 - mountPath: "test", 260 - stopCh: make(chan struct{}), 261 - } 262 - 263 - // Verify the manager implements Stopper interface 264 - var stopper Stopper = manager 265 - assert.NotNil(t, stopper) 266 - 267 - // Call Stop and verify it doesn't panic 268 - assert.NotPanics(t, func() { 269 - manager.Stop() 270 - }) 271 - 272 - // Verify the channel was closed 273 - select { 274 - case <-manager.stopCh: 275 - // Channel was closed as expected 276 - default: 277 - t.Error("Expected stop channel to be closed after Stop()") 278 - } 279 - } 280 - 281 - func TestOpenBaoManager_StopperInterface(t *testing.T) { 282 - manager := &OpenBaoManager{} 283 - 284 - // Verify that OpenBaoManager implements the Stopper interface 285 - _, ok := interface{}(manager).(Stopper) 286 - assert.True(t, ok, "OpenBaoManager should implement Stopper interface") 287 - } 288 - 289 - // Test MockOpenBaoManager interface compliance 290 - func TestMockOpenBaoManagerInterface(t *testing.T) { 291 - var _ Manager = (*MockOpenBaoManager)(nil) 292 - var _ Stopper = (*MockOpenBaoManager)(nil) 293 - } 294 - 295 242 func TestMockOpenBaoManager_AddSecret(t *testing.T) { 296 243 tests := []struct { 297 244 name string ··· 563 510 assert.NoError(t, err) 564 511 } 565 512 566 - func TestMockOpenBaoManager_Stop(t *testing.T) { 567 - mock := NewMockOpenBaoManager() 568 - 569 - assert.False(t, mock.IsStopped()) 570 - 571 - mock.Stop() 572 - 573 - assert.True(t, mock.IsStopped()) 574 - } 575 - 576 513 func TestMockOpenBaoManager_Integration(t *testing.T) { 577 514 tests := []struct { 578 515 name string ··· 628 565 }) 629 566 } 630 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 + }
+13 -6
spindle/secrets/policy.hcl
··· 1 - # KV v2 data operations 2 - path "spindle/data/*" { 1 + # Allow full access to the spindle KV mount 2 + path "spindle/*" { 3 3 capabilities = ["create", "read", "update", "delete", "list"] 4 4 } 5 5 6 - # KV v2 metadata operations (needed for listing) 6 + path "spindle/data/*" { 7 + capabilities = ["create", "read", "update", "delete"] 8 + } 9 + 7 10 path "spindle/metadata/*" { 8 11 capabilities = ["list", "read", "delete"] 9 12 } 10 13 11 - # Root path access (needed for mount-level operations) 12 - path "spindle/*" { 13 - capabilities = ["list"] 14 + # Allow listing mounts (for connection testing) 15 + path "sys/mounts" { 16 + capabilities = ["read"] 14 17 } 15 18 19 + # Allow token self-lookup (for health checks) 20 + path "auth/token/lookup-self" { 21 + capabilities = ["read"] 22 + }
+19 -13
spindle/server.go
··· 71 71 var vault secrets.Manager 72 72 switch cfg.Server.Secrets.Provider { 73 73 case "openbao": 74 - if cfg.Server.Secrets.OpenBao.Addr == "" { 75 - return fmt.Errorf("openbao address is required when using openbao secrets provider") 76 - } 77 - if cfg.Server.Secrets.OpenBao.RoleID == "" { 78 - return fmt.Errorf("openbao role_id is required when using openbao secrets provider") 79 - } 80 - if cfg.Server.Secrets.OpenBao.SecretID == "" { 81 - return fmt.Errorf("openbao secret_id is required when using openbao secrets provider") 74 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 75 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 82 76 } 83 77 vault, err = secrets.NewOpenBaoManager( 84 - cfg.Server.Secrets.OpenBao.Addr, 85 - cfg.Server.Secrets.OpenBao.RoleID, 86 - cfg.Server.Secrets.OpenBao.SecretID, 78 + cfg.Server.Secrets.OpenBao.ProxyAddr, 87 79 logger, 88 80 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 89 81 ) 90 82 if err != nil { 91 83 return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 92 84 } 93 - logger.Info("using openbao secrets provider", "address", cfg.Server.Secrets.OpenBao.Addr, "mount", cfg.Server.Secrets.OpenBao.Mount) 85 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 94 86 case "sqlite", "": 95 87 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 96 88 if err != nil { ··· 106 98 return err 107 99 } 108 100 109 - jq := queue.NewQueue(100, 2) 101 + jq := queue.NewQueue(100, 5) 110 102 111 103 collections := []string{ 112 104 tangled.SpindleMemberNSID, 113 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 114 107 } 115 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 116 109 if err != nil { 117 110 return fmt.Errorf("failed to setup jetstream client: %w", err) 118 111 } 119 112 jc.AddDid(cfg.Server.Owner) 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 + } 120 122 121 123 resolver := idresolver.DefaultResolver() 122 124 ··· 238 240 239 241 if tpl.TriggerMetadata.Repo == nil { 240 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) 241 247 } 242 248 243 249 // filter by repos