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

Compare changes

Choose any two refs to compare.

+2
api/tangled/repodelete.go
··· 20 20 Did string `json:"did" cborgen:"did"` 21 21 // name: Name of the repository to delete 22 22 Name string `json:"name" cborgen:"name"` 23 + // rkey: Rkey of the repository record 24 + Rkey string `json:"rkey" cborgen:"rkey"` 23 25 } 24 26 25 27 // RepoDelete calls the XRPC method "sh.tangled.repo.delete".
+1 -1
appview/config/config.go
··· 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 19 20 - // temporarily, to add users to default spindle 20 + // temporarily, to add users to default knot and spindle 21 21 AppPassword string `env:"APP_PASSWORD"` 22 22 } 23 23
+25
appview/db/db.go
··· 612 612 return nil 613 613 }) 614 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 615 640 // recreate and add rkey + created columns with default constraint 616 641 runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 617 642 // create new table
+89 -125
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/rand" 5 4 "database/sql" 6 - "encoding/hex" 7 5 "fmt" 8 - "log" 6 + "strings" 9 7 "time" 10 8 ) 11 9 10 + // Registration represents a knot registration. Knot would've been a better 11 + // name but we're stuck with this for historical reasons. 12 12 type Registration struct { 13 13 Id int64 14 14 Domain string 15 15 ByDid string 16 16 Created *time.Time 17 17 Registered *time.Time 18 + ReadOnly bool 18 19 } 19 20 20 21 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 + if r.ReadOnly { 23 + return ReadOnly 24 + } else if r.Registered != nil { 22 25 return Registered 23 26 } else { 24 27 return Pending 25 28 } 26 29 } 27 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsReadOnly() bool { 36 + return r.Status() == ReadOnly 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 28 43 type Status uint32 29 44 30 45 const ( 31 46 Registered Status = iota 32 47 Pending 48 + ReadOnly 33 49 ) 34 50 35 - // returns registered status, did of owner, error 36 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 37 52 var registrations []Registration 38 53 39 - rows, err := e.Query(` 40 - select id, domain, did, created, registered from registrations 41 - where did = ? 42 - `, did) 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 59 + } 60 + 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 64 + } 65 + 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, read_only 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 74 + 75 + rows, err := e.Query(query, args...) 43 76 if err != nil { 44 77 return nil, err 45 78 } 46 79 47 80 for rows.Next() { 48 - var createdAt *string 49 - var registeredAt *string 50 - var registration Registration 51 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var readOnly int 84 + var reg Registration 52 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 53 87 if err != nil { 54 - log.Println(err) 55 - } else { 56 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 57 - var registeredAtTime *time.Time 58 - if registeredAt != nil { 59 - x, _ := time.Parse(time.RFC3339, *registeredAt) 60 - registeredAtTime = &x 61 - } 88 + return nil, err 89 + } 62 90 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 66 93 } 67 - } 68 94 69 - return registrations, nil 70 - } 71 - 72 - // returns registered status, did of owner, error 73 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 74 - var createdAt *string 75 - var registeredAt *string 76 - var registration Registration 77 - 78 - err := e.QueryRow(` 79 - select id, domain, did, created, registered from registrations 80 - where domain = ? 81 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 82 100 83 - if err != nil { 84 - if err == sql.ErrNoRows { 85 - return nil, nil 86 - } else { 87 - return nil, err 101 + if readOnly != 0 { 102 + reg.ReadOnly = true 88 103 } 89 - } 90 104 91 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 92 - var registeredAtTime *time.Time 93 - if registeredAt != nil { 94 - x, _ := time.Parse(time.RFC3339, *registeredAt) 95 - registeredAtTime = &x 105 + registrations = append(registrations, reg) 96 106 } 97 107 98 - registration.Created = &createdAtTime 99 - registration.Registered = registeredAtTime 100 - 101 - return &registration, nil 102 - } 103 - 104 - func genSecret() string { 105 - key := make([]byte, 32) 106 - rand.Read(key) 107 - return hex.EncodeToString(key) 108 + return registrations, nil 108 109 } 109 110 110 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 111 - // sanity check: does this domain already have a registration? 112 - reg, err := RegistrationByDomain(e, domain) 113 - if err != nil { 114 - return "", err 115 - } 116 - 117 - // registration is open 118 - if reg != nil { 119 - switch reg.Status() { 120 - case Registered: 121 - // already registered by `owner` 122 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 123 - case Pending: 124 - // TODO: be loud about this 125 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 126 - } 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 127 117 } 128 118 129 - secret := genSecret() 130 - 131 - _, err = e.Exec(` 132 - insert into registrations (domain, did, secret) 133 - values (?, ?, ?) 134 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 135 - `, domain, did, secret) 136 - 137 - if err != nil { 138 - return "", err 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 139 122 } 140 123 141 - return secret, nil 124 + _, err := e.Exec(query, args...) 125 + return err 142 126 } 143 127 144 - func GetRegistrationKey(e Execer, domain string) (string, error) { 145 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 146 - 147 - var secret string 148 - err := res.Scan(&secret) 149 - if err != nil || secret == "" { 150 - return "", err 151 - } 152 - 153 - return secret, nil 128 + func AddKnot(e Execer, domain, did string) error { 129 + _, err := e.Exec(` 130 + insert into registrations (domain, did) 131 + values (?, ?) 132 + `, domain, did) 133 + return err 154 134 } 155 135 156 - func GetCompletedRegistrations(e Execer) ([]string, error) { 157 - rows, err := e.Query(`select domain from registrations where registered not null`) 158 - if err != nil { 159 - return nil, err 136 + func DeleteKnot(e Execer, filters ...filter) error { 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()...) 160 142 } 161 143 162 - var domains []string 163 - for rows.Next() { 164 - var domain string 165 - err = rows.Scan(&domain) 166 - 167 - if err != nil { 168 - log.Println(err) 169 - } else { 170 - domains = append(domains, domain) 171 - } 172 - } 173 - 174 - if err = rows.Err(); err != nil { 175 - return nil, err 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 176 147 } 177 148 178 - return domains, nil 179 - } 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 180 150 181 - func Register(e Execer, domain string) error { 182 - _, err := e.Exec(` 183 - update registrations 184 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 185 - where domain = ?; 186 - `, domain) 187 - 151 + _, err := e.Exec(query, args...) 188 152 return err 189 153 }
+163 -3
appview/ingester.go
··· 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 15 "tangled.sh/tangled.sh/core/appview/config" 16 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/spindleverify" 17 + "tangled.sh/tangled.sh/core/appview/serververify" 18 18 "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/rbac" 20 20 ) ··· 64 64 err = i.ingestSpindleMember(e) 65 65 case tangled.SpindleNSID: 66 66 err = i.ingestSpindle(e) 67 + case tangled.KnotMemberNSID: 68 + err = i.ingestKnotMember(e) 69 + case tangled.KnotNSID: 70 + err = i.ingestKnot(e) 67 71 case tangled.StringNSID: 68 72 err = i.ingestString(e) 69 73 } ··· 475 479 return err 476 480 } 477 481 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 482 + err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 479 483 if err != nil { 480 484 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 485 return err 482 486 } 483 487 484 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 488 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 485 489 if err != nil { 486 490 return fmt.Errorf("failed to mark verified: %w", err) 487 491 } ··· 609 613 610 614 return nil 611 615 } 616 + 617 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 618 + did := e.Did 619 + var err error 620 + 621 + l := i.Logger.With("handler", "ingestKnotMember") 622 + l = l.With("nsid", e.Commit.Collection) 623 + 624 + switch e.Commit.Operation { 625 + case models.CommitOperationCreate: 626 + raw := json.RawMessage(e.Commit.Record) 627 + record := tangled.KnotMember{} 628 + err = json.Unmarshal(raw, &record) 629 + if err != nil { 630 + l.Error("invalid record", "err", err) 631 + return err 632 + } 633 + 634 + // only knot owner can invite to knots 635 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 636 + if err != nil || !ok { 637 + return fmt.Errorf("failed to enforce permissions: %w", err) 638 + } 639 + 640 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 641 + if err != nil { 642 + return err 643 + } 644 + 645 + if memberId.Handle.IsInvalidHandle() { 646 + return err 647 + } 648 + 649 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 650 + if err != nil { 651 + return fmt.Errorf("failed to update ACLs: %w", err) 652 + } 653 + 654 + l.Info("added knot member") 655 + case models.CommitOperationDelete: 656 + // we don't store knot members in a table (like we do for spindle) 657 + // and we can't remove this just yet. possibly fixed if we switch 658 + // to either: 659 + // 1. a knot_members table like with spindle and store the rkey 660 + // 2. use the knot host as the rkey 661 + // 662 + // TODO: implement member deletion 663 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 664 + } 665 + 666 + return nil 667 + } 668 + 669 + func (i *Ingester) ingestKnot(e *models.Event) error { 670 + did := e.Did 671 + var err error 672 + 673 + l := i.Logger.With("handler", "ingestKnot") 674 + l = l.With("nsid", e.Commit.Collection) 675 + 676 + switch e.Commit.Operation { 677 + case models.CommitOperationCreate: 678 + raw := json.RawMessage(e.Commit.Record) 679 + record := tangled.Knot{} 680 + err = json.Unmarshal(raw, &record) 681 + if err != nil { 682 + l.Error("invalid record", "err", err) 683 + return err 684 + } 685 + 686 + domain := e.Commit.RKey 687 + 688 + ddb, ok := i.Db.Execer.(*db.DB) 689 + if !ok { 690 + return fmt.Errorf("failed to index profile record, invalid db cast") 691 + } 692 + 693 + err := db.AddKnot(ddb, domain, did) 694 + if err != nil { 695 + l.Error("failed to add knot to db", "err", err, "domain", domain) 696 + return err 697 + } 698 + 699 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 700 + if err != nil { 701 + l.Error("failed to verify knot", "err", err, "domain", domain) 702 + return err 703 + } 704 + 705 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 706 + if err != nil { 707 + return fmt.Errorf("failed to mark verified: %w", err) 708 + } 709 + 710 + return nil 711 + 712 + case models.CommitOperationDelete: 713 + domain := e.Commit.RKey 714 + 715 + ddb, ok := i.Db.Execer.(*db.DB) 716 + if !ok { 717 + return fmt.Errorf("failed to index knot record, invalid db cast") 718 + } 719 + 720 + // get record from db first 721 + registrations, err := db.GetRegistrations( 722 + ddb, 723 + db.FilterEq("domain", domain), 724 + db.FilterEq("did", did), 725 + ) 726 + if err != nil { 727 + return fmt.Errorf("failed to get registration: %w", err) 728 + } 729 + if len(registrations) != 1 { 730 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 731 + } 732 + registration := registrations[0] 733 + 734 + tx, err := ddb.Begin() 735 + if err != nil { 736 + return err 737 + } 738 + defer func() { 739 + tx.Rollback() 740 + i.Enforcer.E.LoadPolicy() 741 + }() 742 + 743 + err = db.DeleteKnot( 744 + tx, 745 + db.FilterEq("did", did), 746 + db.FilterEq("domain", domain), 747 + ) 748 + if err != nil { 749 + return err 750 + } 751 + 752 + if registration.Registered != nil { 753 + err = i.Enforcer.RemoveKnot(domain) 754 + if err != nil { 755 + return err 756 + } 757 + } 758 + 759 + err = tx.Commit() 760 + if err != nil { 761 + return err 762 + } 763 + 764 + err = i.Enforcer.E.SavePolicy() 765 + if err != nil { 766 + return err 767 + } 768 + } 769 + 770 + return nil 771 + }
+444 -217
appview/knots/knots.go
··· 1 1 package knots 2 2 3 3 import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 4 + "errors" 8 5 "fmt" 6 + "log" 9 7 "log/slog" 10 8 "net/http" 11 - "strings" 9 + "slices" 12 10 "time" 13 11 14 12 "github.com/go-chi/chi/v5" ··· 18 16 "tangled.sh/tangled.sh/core/appview/middleware" 19 17 "tangled.sh/tangled.sh/core/appview/oauth" 20 18 "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/serververify" 21 20 "tangled.sh/tangled.sh/core/eventconsumer" 22 21 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 22 "tangled.sh/tangled.sh/core/rbac" 25 23 "tangled.sh/tangled.sh/core/tid" 26 24 ··· 39 37 Knotstream *eventconsumer.Consumer 40 38 } 41 39 42 - func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 40 + func (k *Knots) Router() http.Handler { 43 41 r := chi.NewRouter() 44 42 45 - r.Use(middleware.AuthMiddleware(k.OAuth)) 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 46 45 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 49 48 50 - r.Route("/{domain}", func(r chi.Router) { 51 - r.Post("/init", k.init) 52 - r.Get("/", k.dashboard) 53 - r.Route("/member", func(r chi.Router) { 54 - r.Use(mw.KnotOwner()) 55 - r.Get("/", k.members) 56 - r.Put("/", k.addMember) 57 - r.Delete("/", k.removeMember) 58 - }) 59 - }) 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 + 53 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 60 54 61 55 return r 62 56 } 63 57 64 - // get knots registered by this user 65 - func (k *Knots) index(w http.ResponseWriter, r *http.Request) { 66 - l := k.Logger.With("handler", "index") 67 - 58 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 68 59 user := k.OAuth.GetUser(r) 69 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 60 + registrations, err := db.GetRegistrations( 61 + k.Db, 62 + db.FilterEq("did", user.Did), 63 + ) 70 64 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 65 + k.Logger.Error("failed to fetch knot registrations", "err", err) 66 + w.WriteHeader(http.StatusInternalServerError) 67 + return 72 68 } 73 69 74 70 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 73 }) 78 74 } 79 75 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 76 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 77 + l := k.Logger.With("handler", "dashboard") 83 78 84 79 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 80 + l = l.With("user", user.Did) 87 81 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 82 + domain := chi.URLParam(r, "domain") 90 83 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 84 return 94 85 } 95 86 l = l.With("domain", domain) 96 87 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 88 + registrations, err := db.GetRegistrations( 89 + k.Db, 90 + db.FilterEq("did", user.Did), 91 + db.FilterEq("domain", domain), 92 + ) 93 + if err != nil { 94 + l.Error("failed to get registrations", "err", err) 95 + http.Error(w, "Not found", http.StatusNotFound) 96 + return 100 97 } 98 + if len(registrations) != 1 { 99 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 100 + return 101 + } 102 + registration := registrations[0] 101 103 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 104 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 105 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 + l.Error("failed to get knot members", "err", err) 107 + http.Error(w, "Not found", http.StatusInternalServerError) 106 108 return 107 109 } 110 + slices.Sort(members) 108 111 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 112 + repos, err := db.GetRepos( 113 + k.Db, 114 + 0, 115 + db.FilterEq("knot", domain), 116 + ) 110 117 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 118 + l.Error("failed to get knot repos", "err", err) 119 + http.Error(w, "Not found", http.StatusInternalServerError) 113 120 return 114 121 } 115 122 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 123 + // organize repos by did 124 + repoMap := make(map[string][]db.Repo) 125 + for _, r := range repos { 126 + repoMap[r.Did] = append(repoMap[r.Did], r) 127 + } 128 + 129 + k.Pages.Knot(w, pages.KnotParams{ 130 + LoggedInUser: user, 131 + Registration: &registration, 132 + Members: members, 133 + Repos: repoMap, 134 + IsOwner: true, 121 135 }) 122 136 } 123 137 124 - // create a signed request and check if a node responds to that 125 - func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 - l := k.Logger.With("handler", "init") 138 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 127 139 user := k.OAuth.GetUser(r) 140 + l := k.Logger.With("handler", "register") 128 141 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 142 + noticeId := "register-error" 143 + defaultErr := "Failed to register knot. Try again later." 131 144 fail := func() { 132 145 k.Pages.Notice(w, noticeId, defaultErr) 133 146 } 134 147 135 - domain := chi.URLParam(r, "domain") 148 + domain := r.FormValue("domain") 136 149 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 150 + k.Pages.Notice(w, noticeId, "Incomplete form.") 138 151 return 139 152 } 140 153 l = l.With("domain", domain) 154 + l = l.With("user", user.Did) 141 155 142 - l.Info("checking domain") 156 + tx, err := k.Db.Begin() 157 + if err != nil { 158 + l.Error("failed to start transaction", "err", err) 159 + fail() 160 + return 161 + } 162 + defer func() { 163 + tx.Rollback() 164 + k.Enforcer.E.LoadPolicy() 165 + }() 143 166 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 167 + err = db.AddKnot(tx, domain, user.Did) 145 168 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 169 + l.Error("failed to insert", "err", err) 147 170 fail() 148 171 return 149 172 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 173 + 174 + err = k.Enforcer.AddKnot(domain) 175 + if err != nil { 176 + l.Error("failed to create knot", "err", err) 177 + fail() 153 178 return 154 179 } 155 180 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 181 + // create record on pds 182 + client, err := k.OAuth.AuthorizedClient(r) 157 183 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 184 + l.Error("failed to authorize client", "err", err) 159 185 fail() 160 186 return 161 187 } 162 188 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 189 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 190 + var exCid *string 191 + if ex != nil { 192 + exCid = ex.Cid 193 + } 194 + 195 + // re-announce by registering under same rkey 196 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + Collection: tangled.KnotNSID, 198 + Repo: user.Did, 199 + Rkey: domain, 200 + Record: &lexutil.LexiconTypeDecoder{ 201 + Val: &tangled.Knot{ 202 + CreatedAt: time.Now().Format(time.RFC3339), 203 + }, 204 + }, 205 + SwapRecord: exCid, 206 + }) 207 + 164 208 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 209 + l.Error("failed to put record", "err", err) 166 210 fail() 167 211 return 168 212 } 169 213 170 - resp, err := client.Init(user.Did) 214 + err = tx.Commit() 171 215 if err != nil { 172 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 - l.Error("failed to make init request", "err", err) 216 + l.Error("failed to commit transaction", "err", err) 217 + fail() 174 218 return 175 219 } 176 220 177 - if resp.StatusCode == http.StatusConflict { 178 - k.Pages.Notice(w, noticeId, "This knot is already registered") 179 - l.Error("knot already registered", "statuscode", resp.StatusCode) 221 + err = k.Enforcer.E.SavePolicy() 222 + if err != nil { 223 + l.Error("failed to update ACL", "err", err) 224 + k.Pages.HxRefresh(w) 180 225 return 181 226 } 182 227 183 - if resp.StatusCode != http.StatusNoContent { 184 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 - l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 228 + // begin verification 229 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 230 + if err != nil { 231 + l.Error("verification failed", "err", err) 232 + k.Pages.HxRefresh(w) 186 233 return 187 234 } 188 235 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 236 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 192 237 if err != nil { 238 + l.Error("failed to mark verified", "err", err) 239 + k.Pages.HxRefresh(w) 193 240 return 194 241 } 195 242 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 243 + // add this knot to knotstream 244 + go k.Knotstream.AddSource( 245 + r.Context(), 246 + eventconsumer.NewKnotSource(domain), 247 + ) 248 + 249 + // ok 250 + k.Pages.HxRefresh(w) 251 + } 252 + 253 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 254 + user := k.OAuth.GetUser(r) 255 + l := k.Logger.With("handler", "delete") 256 + 257 + noticeId := "operation-error" 258 + defaultErr := "Failed to delete knot. Try again later." 259 + fail := func() { 260 + k.Pages.Notice(w, noticeId, defaultErr) 261 + } 262 + 263 + domain := chi.URLParam(r, "domain") 264 + if domain == "" { 265 + l.Error("empty domain") 266 + fail() 267 + return 268 + } 198 269 199 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 - l.Error("signature mismatch", "bytes", signatureBytes) 270 + // get record from db first 271 + registrations, err := db.GetRegistrations( 272 + k.Db, 273 + db.FilterEq("did", user.Did), 274 + db.FilterEq("domain", domain), 275 + ) 276 + if err != nil { 277 + l.Error("failed to get registration", "err", err) 278 + fail() 202 279 return 203 280 } 281 + if len(registrations) != 1 { 282 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 283 + fail() 284 + return 285 + } 286 + registration := registrations[0] 204 287 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 288 + tx, err := k.Db.Begin() 206 289 if err != nil { 207 - l.Error("failed to start tx", "err", err) 290 + l.Error("failed to start txn", "err", err) 208 291 fail() 209 292 return 210 293 } 211 294 defer func() { 212 295 tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 296 + k.Enforcer.E.LoadPolicy() 217 297 }() 218 298 219 - // mark as registered 220 - err = db.Register(tx, domain) 299 + err = db.DeleteKnot( 300 + tx, 301 + db.FilterEq("did", user.Did), 302 + db.FilterEq("domain", domain), 303 + ) 221 304 if err != nil { 222 - l.Error("failed to register domain", "err", err) 305 + l.Error("failed to delete registration", "err", err) 223 306 fail() 224 307 return 225 308 } 226 309 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 229 - if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 231 - fail() 232 - return 310 + // delete from enforcer if it was registered 311 + if registration.Registered != nil { 312 + err = k.Enforcer.RemoveKnot(domain) 313 + if err != nil { 314 + l.Error("failed to update ACL", "err", err) 315 + fail() 316 + return 317 + } 233 318 } 234 319 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 320 + client, err := k.OAuth.AuthorizedClient(r) 237 321 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 322 + l.Error("failed to authorize client", "err", err) 239 323 fail() 240 324 return 241 325 } 242 326 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 327 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 328 + Collection: tangled.KnotNSID, 329 + Repo: user.Did, 330 + Rkey: domain, 331 + }) 245 332 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 - fail() 248 - return 333 + // non-fatal 334 + l.Error("failed to delete record", "err", err) 249 335 } 250 336 251 337 err = tx.Commit() 252 338 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 339 + l.Error("failed to delete knot", "err", err) 254 340 fail() 255 341 return 256 342 } 257 343 258 344 err = k.Enforcer.E.SavePolicy() 259 345 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 346 + l.Error("failed to update ACL", "err", err) 347 + k.Pages.HxRefresh(w) 262 348 return 263 349 } 264 350 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 351 + shouldRedirect := r.Header.Get("shouldRedirect") 352 + if shouldRedirect == "true" { 353 + k.Pages.HxRedirect(w, "/knots") 354 + return 355 + } 270 356 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 357 + w.Write([]byte{}) 274 358 } 275 359 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 360 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 361 + user := k.OAuth.GetUser(r) 362 + l := k.Logger.With("handler", "retry") 363 + 364 + noticeId := "operation-error" 365 + defaultErr := "Failed to verify knot. Try again later." 278 366 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 367 + k.Pages.Notice(w, noticeId, defaultErr) 280 368 } 281 369 282 370 domain := chi.URLParam(r, "domain") 283 371 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 372 + l.Error("empty domain") 373 + fail() 285 374 return 286 375 } 287 376 l = l.With("domain", domain) 377 + l = l.With("user", user.Did) 288 378 289 - user := k.OAuth.GetUser(r) 290 - l = l.With("did", user.Did) 291 - 292 - // dashboard is only available to owners 293 - ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 379 + // get record from db first 380 + registrations, err := db.GetRegistrations( 381 + k.Db, 382 + db.FilterEq("did", user.Did), 383 + db.FilterEq("domain", domain), 384 + ) 294 385 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 386 + l.Error("failed to get registration", "err", err) 296 387 fail() 388 + return 297 389 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 390 + if len(registrations) != 1 { 391 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 392 + fail() 300 393 return 301 394 } 395 + registration := registrations[0] 302 396 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 397 + // begin verification 398 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 304 399 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 400 + l.Error("verification failed", "err", err) 401 + 402 + if errors.Is(err, serververify.FetchError) { 403 + k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 + return 405 + } 406 + 407 + if e, ok := err.(*serververify.OwnerMismatch); ok { 408 + k.Pages.Notice(w, noticeId, e.Error()) 409 + return 410 + } 411 + 306 412 fail() 307 413 return 308 414 } 309 415 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 416 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 417 + if err != nil { 418 + l.Error("failed to mark verified", "err", err) 419 + k.Pages.Notice(w, noticeId, err.Error()) 420 + return 421 + } 422 + 423 + // if this knot was previously read-only, then emit a record too 424 + // 425 + // this is part of migrating from the old knot system to the new one 426 + if registration.ReadOnly { 427 + // re-announce by registering under same rkey 428 + client, err := k.OAuth.AuthorizedClient(r) 313 429 if err != nil { 314 - l.Error("failed to get members list", "err", err) 430 + l.Error("failed to authorize client", "err", err) 315 431 fail() 316 432 return 317 433 } 434 + 435 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 436 + var exCid *string 437 + if ex != nil { 438 + exCid = ex.Cid 439 + } 440 + 441 + // ignore the error here 442 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 443 + Collection: tangled.KnotNSID, 444 + Repo: user.Did, 445 + Rkey: domain, 446 + Record: &lexutil.LexiconTypeDecoder{ 447 + Val: &tangled.Knot{ 448 + CreatedAt: time.Now().Format(time.RFC3339), 449 + }, 450 + }, 451 + SwapRecord: exCid, 452 + }) 453 + if err != nil { 454 + l.Error("non-fatal: failed to reannouce knot", "err", err) 455 + } 318 456 } 319 457 320 - repos, err := db.GetRepos( 458 + // add this knot to knotstream 459 + go k.Knotstream.AddSource( 460 + r.Context(), 461 + eventconsumer.NewKnotSource(domain), 462 + ) 463 + 464 + shouldRefresh := r.Header.Get("shouldRefresh") 465 + if shouldRefresh == "true" { 466 + k.Pages.HxRefresh(w) 467 + return 468 + } 469 + 470 + // Get updated registration to show 471 + registrations, err = db.GetRegistrations( 321 472 k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 473 + db.FilterEq("did", user.Did), 474 + db.FilterEq("domain", domain), 325 475 ) 326 476 if err != nil { 327 - l.Error("failed to get repos list", "err", err) 477 + l.Error("failed to get registration", "err", err) 328 478 fail() 329 479 return 330 480 } 331 - // convert to map 332 - repoByMember := make(map[string][]db.Repo) 333 - for _, r := range repos { 334 - repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 - } 336 - 337 - k.Pages.Knot(w, pages.KnotParams{ 338 - LoggedInUser: user, 339 - Registration: reg, 340 - Members: members, 341 - Repos: repoByMember, 342 - IsOwner: true, 343 - }) 344 - } 345 - 346 - // list members of domain, requires auth and requires owner status 347 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 348 - l := k.Logger.With("handler", "members") 349 - 350 - domain := chi.URLParam(r, "domain") 351 - if domain == "" { 352 - http.Error(w, "malformed url", http.StatusBadRequest) 481 + if len(registrations) != 1 { 482 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 483 + fail() 353 484 return 354 485 } 355 - l = l.With("domain", domain) 486 + updatedRegistration := registrations[0] 356 487 357 - // list all members for this domain 358 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 359 - if err != nil { 360 - w.Write([]byte("failed to fetch member list")) 361 - return 362 - } 488 + log.Println(updatedRegistration) 363 489 364 - w.Write([]byte(strings.Join(memberDids, "\n"))) 490 + w.Header().Set("HX-Reswap", "outerHTML") 491 + k.Pages.KnotListing(w, pages.KnotListingParams{ 492 + Registration: &updatedRegistration, 493 + }) 365 494 } 366 495 367 - // add member to domain, requires auth and requires invite access 368 496 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 369 - l := k.Logger.With("handler", "members") 497 + user := k.OAuth.GetUser(r) 498 + l := k.Logger.With("handler", "addMember") 370 499 371 500 domain := chi.URLParam(r, "domain") 372 501 if domain == "" { 373 - http.Error(w, "malformed url", http.StatusBadRequest) 502 + l.Error("empty domain") 503 + http.Error(w, "Not found", http.StatusNotFound) 374 504 return 375 505 } 376 506 l = l.With("domain", domain) 507 + l = l.With("user", user.Did) 377 508 378 - reg, err := db.RegistrationByDomain(k.Db, domain) 509 + registrations, err := db.GetRegistrations( 510 + k.Db, 511 + db.FilterEq("did", user.Did), 512 + db.FilterEq("domain", domain), 513 + db.FilterIsNot("registered", "null"), 514 + ) 379 515 if err != nil { 380 - l.Error("failed to get registration by domain", "err", err) 381 - http.Error(w, "malformed url", http.StatusBadRequest) 516 + l.Error("failed to get registration", "err", err) 517 + return 518 + } 519 + if len(registrations) != 1 { 520 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 382 521 return 383 522 } 523 + registration := registrations[0] 384 524 385 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 386 - l = l.With("notice-id", noticeId) 525 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 387 526 defaultErr := "Failed to add member. Try again later." 388 527 fail := func() { 389 528 k.Pages.Notice(w, noticeId, defaultErr) 390 529 } 391 530 392 - subjectIdentifier := r.FormValue("subject") 393 - if subjectIdentifier == "" { 394 - http.Error(w, "malformed form", http.StatusBadRequest) 531 + member := r.FormValue("member") 532 + if member == "" { 533 + l.Error("empty member") 534 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 395 535 return 396 536 } 397 - l = l.With("subjectIdentifier", subjectIdentifier) 537 + l = l.With("member", member) 398 538 399 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 539 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 400 540 if err != nil { 401 - l.Error("failed to resolve identity", "err", err) 541 + l.Error("failed to resolve member identity to handle", "err", err) 402 542 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 403 543 return 404 544 } 405 - l = l.With("subjectDid", subjectIdentity.DID) 406 - 407 - l.Info("adding member to knot") 545 + if memberId.Handle.IsInvalidHandle() { 546 + l.Error("failed to resolve member identity to handle") 547 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 548 + return 549 + } 408 550 409 - // announce this relation into the firehose, store into owners' pds 551 + // write to pds 410 552 client, err := k.OAuth.AuthorizedClient(r) 411 553 if err != nil { 412 - l.Error("failed to create client", "err", err) 554 + l.Error("failed to authorize client", "err", err) 413 555 fail() 414 556 return 415 557 } 416 558 417 - currentUser := k.OAuth.GetUser(r) 418 - createdAt := time.Now().Format(time.RFC3339) 419 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 + rkey := tid.TID() 560 + 561 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 420 562 Collection: tangled.KnotMemberNSID, 421 - Repo: currentUser.Did, 422 - Rkey: tid.TID(), 563 + Repo: user.Did, 564 + Rkey: rkey, 423 565 Record: &lexutil.LexiconTypeDecoder{ 424 566 Val: &tangled.KnotMember{ 425 - Subject: subjectIdentity.DID.String(), 567 + CreatedAt: time.Now().Format(time.RFC3339), 426 568 Domain: domain, 427 - CreatedAt: createdAt, 428 - }}, 569 + Subject: memberId.DID.String(), 570 + }, 571 + }, 429 572 }) 430 - // invalid record 573 + if err != nil { 574 + l.Error("failed to add record to PDS", "err", err) 575 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 576 + return 577 + } 578 + 579 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 431 580 if err != nil { 432 - l.Error("failed to write to PDS", "err", err) 581 + l.Error("failed to add member to ACLs", "err", err) 433 582 fail() 434 583 return 435 584 } 436 - l = l.With("at-uri", resp.Uri) 437 - l.Info("wrote record to PDS") 438 585 439 - secret, err := db.GetRegistrationKey(k.Db, domain) 586 + err = k.Enforcer.E.SavePolicy() 440 587 if err != nil { 441 - l.Error("failed to get registration key", "err", err) 588 + l.Error("failed to save ACL policy", "err", err) 589 + fail() 590 + return 591 + } 592 + 593 + // success 594 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 595 + } 596 + 597 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 598 + user := k.OAuth.GetUser(r) 599 + l := k.Logger.With("handler", "removeMember") 600 + 601 + noticeId := "operation-error" 602 + defaultErr := "Failed to remove member. Try again later." 603 + fail := func() { 604 + k.Pages.Notice(w, noticeId, defaultErr) 605 + } 606 + 607 + domain := chi.URLParam(r, "domain") 608 + if domain == "" { 609 + l.Error("empty domain") 442 610 fail() 443 611 return 444 612 } 613 + l = l.With("domain", domain) 614 + l = l.With("user", user.Did) 445 615 446 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 616 + registrations, err := db.GetRegistrations( 617 + k.Db, 618 + db.FilterEq("did", user.Did), 619 + db.FilterEq("domain", domain), 620 + db.FilterIsNot("registered", "null"), 621 + ) 447 622 if err != nil { 448 - l.Error("failed to create client", "err", err) 449 - fail() 623 + l.Error("failed to get registration", "err", err) 624 + return 625 + } 626 + if len(registrations) != 1 { 627 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 628 + return 629 + } 630 + 631 + member := r.FormValue("member") 632 + if member == "" { 633 + l.Error("empty member") 634 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 450 635 return 451 636 } 637 + l = l.With("member", member) 452 638 453 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 639 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 454 640 if err != nil { 455 - l.Error("failed to reach knotserver", "err", err) 456 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 641 + l.Error("failed to resolve member identity to handle", "err", err) 642 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 + return 644 + } 645 + if memberId.Handle.IsInvalidHandle() { 646 + l.Error("failed to resolve member identity to handle") 647 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 457 648 return 458 649 } 459 650 460 - if ksResp.StatusCode != http.StatusNoContent { 461 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 462 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 651 + // remove from enforcer 652 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 653 + if err != nil { 654 + l.Error("failed to update ACLs", "err", err) 655 + fail() 463 656 return 464 657 } 465 658 466 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 659 + client, err := k.OAuth.AuthorizedClient(r) 467 660 if err != nil { 468 - l.Error("failed to add member to enforcer", "err", err) 661 + l.Error("failed to authorize client", "err", err) 469 662 fail() 470 663 return 471 664 } 472 665 473 - // success 474 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 666 + // TODO: We need to track the rkey for knot members to delete the record 667 + // For now, just remove from ACLs 668 + _ = client 669 + 670 + // commit everything 671 + err = k.Enforcer.E.SavePolicy() 672 + if err != nil { 673 + l.Error("failed to save ACLs", "err", err) 674 + fail() 675 + return 676 + } 677 + 678 + // ok 679 + k.Pages.HxRefresh(w) 475 680 } 476 681 477 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 682 + func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 + user := k.OAuth.GetUser(r) 684 + l := k.Logger.With("handler", "removeMember") 685 + l = l.With("did", user.Did) 686 + l = l.With("handle", user.Handle) 687 + 688 + registrations, err := db.GetRegistrations( 689 + k.Db, 690 + db.FilterEq("did", user.Did), 691 + db.FilterEq("read_only", 1), 692 + ) 693 + if err != nil { 694 + l.Error("non-fatal: failed to get registrations") 695 + return 696 + } 697 + 698 + if registrations == nil { 699 + return 700 + } 701 + 702 + k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 + Registrations: registrations, 704 + }) 478 705 }
+98 -82
appview/oauth/handler/handler.go
··· 8 8 "log" 9 9 "net/http" 10 10 "net/url" 11 + "slices" 11 12 "strings" 12 13 "time" 13 14 ··· 25 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 26 27 "tangled.sh/tangled.sh/core/appview/pages" 27 28 "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/knotclient" 29 29 "tangled.sh/tangled.sh/core/rbac" 30 30 "tangled.sh/tangled.sh/core/tid" 31 31 ) ··· 353 353 return pubKey, nil 354 354 } 355 355 356 + var ( 357 + tangledHandle = "tangled.sh" 358 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 + defaultSpindle = "spindle.tangled.sh" 360 + defaultKnot = "knot1.tangled.sh" 361 + ) 362 + 356 363 func (o *OAuthHandler) addToDefaultSpindle(did string) { 357 364 // use the tangled.sh app password to get an accessJwt 358 365 // and create an sh.tangled.spindle.member record with that 359 - 360 - defaultSpindle := "spindle.tangled.sh" 361 - appPassword := o.config.Core.AppPassword 362 - 363 366 spindleMembers, err := db.GetSpindleMembers( 364 367 o.db, 365 368 db.FilterEq("instance", "spindle.tangled.sh"), ··· 375 378 return 376 379 } 377 380 378 - // TODO: hardcoded tangled handle and did for now 379 - tangledHandle := "tangled.sh" 380 - tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 381 + log.Printf("adding %s to default spindle", did) 382 + session, err := o.createAppPasswordSession() 383 + if err != nil { 384 + log.Printf("failed to create session: %s", err) 385 + return 386 + } 387 + 388 + record := tangled.SpindleMember{ 389 + LexiconTypeID: "sh.tangled.spindle.member", 390 + Subject: did, 391 + Instance: defaultSpindle, 392 + CreatedAt: time.Now().Format(time.RFC3339), 393 + } 394 + 395 + if err := session.putRecord(record); err != nil { 396 + log.Printf("failed to add member to default knot: %s", err) 397 + return 398 + } 399 + 400 + log.Printf("successfully added %s to default spindle", did) 401 + } 402 + 403 + func (o *OAuthHandler) addToDefaultKnot(did string) { 404 + // use the tangled.sh app password to get an accessJwt 405 + // and create an sh.tangled.spindle.member record with that 406 + 407 + allKnots, err := o.enforcer.GetKnotsForUser(did) 408 + if err != nil { 409 + log.Printf("failed to get knot members for did %s: %v", did, err) 410 + return 411 + } 412 + 413 + if slices.Contains(allKnots, defaultKnot) { 414 + log.Printf("did %s is already a member of the default knot", did) 415 + return 416 + } 381 417 382 - if appPassword == "" { 383 - log.Println("no app password configured, skipping spindle member addition") 418 + log.Printf("adding %s to default knot", did) 419 + session, err := o.createAppPasswordSession() 420 + if err != nil { 421 + log.Printf("failed to create session: %s", err) 384 422 return 385 423 } 386 424 387 - log.Printf("adding %s to default spindle", did) 425 + record := tangled.KnotMember{ 426 + LexiconTypeID: "sh.tangled.knot.member", 427 + Subject: did, 428 + Domain: defaultKnot, 429 + CreatedAt: time.Now().Format(time.RFC3339), 430 + } 431 + 432 + if err := session.putRecord(record); err != nil { 433 + log.Printf("failed to add member to default knot: %s", err) 434 + return 435 + } 436 + 437 + log.Printf("successfully added %s to default Knot", did) 438 + } 439 + 440 + // create a session using apppasswords 441 + type session struct { 442 + AccessJwt string `json:"accessJwt"` 443 + PdsEndpoint string 444 + } 445 + 446 + func (o *OAuthHandler) createAppPasswordSession() (*session, error) { 447 + appPassword := o.config.Core.AppPassword 448 + if appPassword == "" { 449 + return nil, fmt.Errorf("no app password configured, skipping member addition") 450 + } 388 451 389 452 resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 390 453 if err != nil { 391 - log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 392 - return 454 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 393 455 } 394 456 395 457 pdsEndpoint := resolved.PDSEndpoint() 396 458 if pdsEndpoint == "" { 397 - log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 398 - return 459 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 399 460 } 400 461 401 462 sessionPayload := map[string]string{ ··· 404 465 } 405 466 sessionBytes, err := json.Marshal(sessionPayload) 406 467 if err != nil { 407 - log.Printf("failed to marshal session payload: %v", err) 408 - return 468 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 409 469 } 410 470 411 471 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 412 472 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 413 473 if err != nil { 414 - log.Printf("failed to create session request: %v", err) 415 - return 474 + return nil, fmt.Errorf("failed to create session request: %v", err) 416 475 } 417 476 sessionReq.Header.Set("Content-Type", "application/json") 418 477 419 478 client := &http.Client{Timeout: 30 * time.Second} 420 479 sessionResp, err := client.Do(sessionReq) 421 480 if err != nil { 422 - log.Printf("failed to create session: %v", err) 423 - return 481 + return nil, fmt.Errorf("failed to create session: %v", err) 424 482 } 425 483 defer sessionResp.Body.Close() 426 484 427 485 if sessionResp.StatusCode != http.StatusOK { 428 - log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 429 - return 486 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 430 487 } 431 488 432 - var session struct { 433 - AccessJwt string `json:"accessJwt"` 434 - } 489 + var session session 435 490 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 436 - log.Printf("failed to decode session response: %v", err) 437 - return 491 + return nil, fmt.Errorf("failed to decode session response: %v", err) 438 492 } 439 493 440 - record := tangled.SpindleMember{ 441 - LexiconTypeID: "sh.tangled.spindle.member", 442 - Subject: did, 443 - Instance: defaultSpindle, 444 - CreatedAt: time.Now().Format(time.RFC3339), 445 - } 494 + session.PdsEndpoint = pdsEndpoint 495 + 496 + return &session, nil 497 + } 446 498 499 + func (s *session) putRecord(record any) error { 447 500 recordBytes, err := json.Marshal(record) 448 501 if err != nil { 449 - log.Printf("failed to marshal spindle member record: %v", err) 450 - return 502 + return fmt.Errorf("failed to marshal knot member record: %w", err) 451 503 } 452 504 453 - payload := map[string]interface{}{ 505 + payload := map[string]any{ 454 506 "repo": tangledDid, 455 - "collection": tangled.SpindleMemberNSID, 507 + "collection": tangled.KnotMemberNSID, 456 508 "rkey": tid.TID(), 457 509 "record": json.RawMessage(recordBytes), 458 510 } 459 511 460 512 payloadBytes, err := json.Marshal(payload) 461 513 if err != nil { 462 - log.Printf("failed to marshal request payload: %v", err) 463 - return 514 + return fmt.Errorf("failed to marshal request payload: %w", err) 464 515 } 465 516 466 - url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 517 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 467 518 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 468 519 if err != nil { 469 - log.Printf("failed to create HTTP request: %v", err) 470 - return 520 + return fmt.Errorf("failed to create HTTP request: %w", err) 471 521 } 472 522 473 523 req.Header.Set("Content-Type", "application/json") 474 - req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 524 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 475 525 526 + client := &http.Client{Timeout: 30 * time.Second} 476 527 resp, err := client.Do(req) 477 528 if err != nil { 478 - log.Printf("failed to add user to default spindle: %v", err) 479 - return 529 + return fmt.Errorf("failed to add user to default Knot: %w", err) 480 530 } 481 531 defer resp.Body.Close() 482 532 483 533 if resp.StatusCode != http.StatusOK { 484 - log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 485 - return 486 - } 487 - 488 - log.Printf("successfully added %s to default spindle", did) 489 - } 490 - 491 - func (o *OAuthHandler) addToDefaultKnot(did string) { 492 - defaultKnot := "knot1.tangled.sh" 493 - 494 - log.Printf("adding %s to default knot", did) 495 - err := o.enforcer.AddKnotMember(defaultKnot, did) 496 - if err != nil { 497 - log.Println("failed to add user to knot1.tangled.sh: ", err) 498 - return 499 - } 500 - err = o.enforcer.E.SavePolicy() 501 - if err != nil { 502 - log.Println("failed to add user to knot1.tangled.sh: ", err) 503 - return 504 - } 505 - 506 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 507 - if err != nil { 508 - log.Println("failed to get registration key for knot1.tangled.sh") 509 - return 510 - } 511 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 512 - resp, err := signedClient.AddMember(did) 513 - if err != nil { 514 - log.Println("failed to add user to knot1.tangled.sh: ", err) 515 - return 534 + return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode) 516 535 } 517 536 518 - if resp.StatusCode != http.StatusNoContent { 519 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 520 - return 521 - } 537 + return nil 522 538 }
+3
appview/oauth/oauth.go
··· 286 286 AccessJwt: resp.Token, 287 287 }, 288 288 Host: opts.Host(), 289 + Client: &http.Client{ 290 + Timeout: time.Second * 5, 291 + }, 289 292 }, nil 290 293 } 291 294
+17 -25
appview/pages/pages.go
··· 338 338 return p.execute("user/settings/emails", w, params) 339 339 } 340 340 341 + type KnotBannerParams struct { 342 + Registrations []db.Registration 343 + } 344 + 345 + func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 + return p.executePlain("knots/fragments/banner", w, params) 347 + } 348 + 341 349 type KnotsParams struct { 342 350 LoggedInUser *oauth.User 343 351 Registrations []db.Registration ··· 360 368 } 361 369 362 370 type KnotListingParams struct { 363 - db.Registration 371 + *db.Registration 364 372 } 365 373 366 374 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 367 375 return p.executePlain("knots/fragments/knotListing", w, params) 368 - } 369 - 370 - type KnotListingFullParams struct { 371 - Registrations []db.Registration 372 - } 373 - 374 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 375 - return p.executePlain("knots/fragments/knotListingFull", w, params) 376 - } 377 - 378 - type KnotSecretParams struct { 379 - Secret string 380 - } 381 - 382 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 383 - return p.executePlain("knots/fragments/secret", w, params) 384 376 } 385 377 386 378 type SpindlesParams struct { ··· 547 539 } 548 540 549 541 type RepoIndexParams struct { 550 - LoggedInUser *oauth.User 551 - RepoInfo repoinfo.RepoInfo 552 - Active string 553 - TagMap map[string][]string 554 - CommitsTrunc []*object.Commit 555 - TagsTrunc []*types.TagReference 556 - BranchesTrunc []types.Branch 557 - ForkInfo *types.ForkInfo 542 + LoggedInUser *oauth.User 543 + RepoInfo repoinfo.RepoInfo 544 + Active string 545 + TagMap map[string][]string 546 + CommitsTrunc []*object.Commit 547 + TagsTrunc []*types.TagReference 548 + BranchesTrunc []types.Branch 549 + // ForkInfo *types.ForkInfo 558 550 HTMLReadme template.HTML 559 551 Raw bool 560 552 EmailToDidOrHandle map[string]string
+93 -28
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <div id="left-side" class="flex gap-2 items-center"> 7 - <h1 class="text-xl font-bold dark:text-white"> 8 - {{ .Registration.Domain }} 9 - </h1> 10 - <span class="text-gray-500 text-base"> 11 - {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 13 + {{ template "knots/fragments/addMemberModal" .Registration }} 14 + {{ end }} 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 12 18 </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 18 - {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 21 26 {{ end }} 22 - </div> 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 23 32 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 26 36 27 - {{ if .Members }} 28 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 - <div class="flex flex-col gap-2"> 30 - {{ block "knotMember" . }} {{ end }} 31 - </div> 32 - </section> 33 - {{ end }} 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 34 44 {{ end }} 35 45 36 - {{ define "knotMember" }} 46 + 47 + {{ define "member" }} 37 48 {{ range .Members }} 38 49 <div> 39 50 <div class="flex justify-between items-center"> ··· 41 52 {{ template "user/fragments/picHandleLink" . }} 42 53 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 44 58 </div> 45 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 60 {{ $repos := index $.Repos . }} ··· 53 67 </div> 54 68 {{ else }} 55 69 <div class="text-gray-500 dark:text-gray-400"> 56 - No repositories created yet. 70 + No repositories configured yet. 57 71 </div> 58 72 {{ end }} 59 73 </div> 60 74 </div> 61 75 {{ end }} 62 76 {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 1 {{ define "knots/fragments/addMemberModal" }} 2 2 <button 3 3 class="btn gap-2 group" 4 - title="Add member to this spindle" 4 + title="Add member to this knot" 5 5 popovertarget="add-member-{{ .Id }}" 6 6 popovertargetaction="toggle" 7 7 > ··· 20 20 21 21 {{ define "addKnotMemberPopover" }} 22 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 23 + hx-post="/knots/{{ .Domain }}/add" 24 24 hx-indicator="#spinner" 25 25 hx-swap="none" 26 26 class="flex flex-col gap-2" ··· 28 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 29 ADD MEMBER 30 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 32 <input 33 33 type="text" 34 34 id="member-did-{{ .Id }}" 35 - name="subject" 35 + name="member" 36 36 required 37 37 placeholder="@foo.bsky.social" 38 38 /> 39 39 <div class="flex gap-2 pt-2"> 40 - <button 40 + <button 41 41 type="button" 42 42 popovertarget="add-member-{{ .Id }}" 43 43 popovertargetaction="hide" ··· 54 54 </div> 55 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 56 </form> 57 - {{ end }} 58 - 57 + {{ end }}
+9
appview/pages/templates/knots/fragments/banner.html
··· 1 + {{ define "knots/fragments/banner" }} 2 + <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 + A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 + that you administer is presently read-only. Consider upgrading this knot to 5 + continue creating repositories on it. 6 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 + </div> 8 + {{ end }} 9 +
+57 -25
appview/pages/templates/knots/fragments/knotListing.html
··· 1 1 {{ define "knots/fragments/knotListing" }} 2 - <div 3 - id="knot-{{.Id}}" 4 - hx-swap-oob="true" 5 - class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 - {{ block "listLeftSide" . }} {{ end }} 7 - {{ block "listRightSide" . }} {{ end }} 2 + <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "knotLeftSide" . }} {{ end }} 4 + {{ block "knotRightSide" . }} {{ end }} 8 5 </div> 9 6 {{ end }} 10 7 11 - {{ define "listLeftSide" }} 8 + {{ define "knotLeftSide" }} 9 + {{ if .Registered }} 10 + <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 12 20 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 21 {{ i "hard-drive" "w-4 h-4" }} 14 - {{ if .Registered }} 15 - <a href="/knots/{{ .Domain }}"> 16 - {{ .Domain }} 17 - </a> 18 - {{ else }} 19 - {{ .Domain }} 20 - {{ end }} 22 + {{ .Domain }} 21 23 <span class="text-gray-500"> 22 24 {{ template "repo/fragments/shortTimeAgo" .Created }} 23 25 </span> 24 26 </div> 27 + {{ end }} 25 28 {{ end }} 26 29 27 - {{ define "listRightSide" }} 30 + {{ define "knotRightSide" }} 28 31 <div id="right-side" class="flex gap-2"> 29 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 30 - {{ if .Registered }} 31 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 32 37 {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsReadOnly }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} read-only 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 33 45 {{ else }} 34 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 35 - {{ block "initializeButton" . }} {{ end }} 46 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 + {{ i "shield-off" "w-4 h-4" }} unverified 48 + </span> 49 + {{ block "knotRetryButton" . }} {{ end }} 50 + {{ block "knotDeleteButton" . }} {{ end }} 36 51 {{ end }} 37 52 </div> 38 53 {{ end }} 39 54 40 - {{ define "initializeButton" }} 55 + {{ define "knotDeleteButton" }} 56 + <button 57 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 + title="Delete knot" 59 + hx-delete="/knots/{{ .Domain }}" 60 + hx-swap="outerHTML" 61 + hx-target="#knot-{{.Id}}" 62 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 41 72 <button 42 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 43 - hx-post="/knots/{{ .Domain }}/init" 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 44 76 hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 45 78 > 46 - {{ i "square-play" "w-5 h-5" }} 47 - <span class="hidden md:inline">initialize</span> 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 48 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 82 </button> 50 83 {{ end }} 51 -
-18
appview/pages/templates/knots/fragments/knotListingFull.html
··· 1 - {{ define "knots/fragments/knotListingFull" }} 2 - <section 3 - id="knot-listing-full" 4 - hx-swap-oob="true" 5 - class="rounded w-full flex flex-col gap-2"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 7 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 8 - {{ range $knot := .Registrations }} 9 - {{ template "knots/fragments/knotListing" . }} 10 - {{ else }} 11 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 12 - no knots registered yet 13 - </div> 14 - {{ end }} 15 - </div> 16 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 17 - </section> 18 - {{ end }}
-10
appview/pages/templates/knots/fragments/secret.html
··· 1 - {{ define "knots/fragments/secret" }} 2 - <div 3 - id="secret" 4 - hx-swap-oob="true" 5 - class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2> 7 - <p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p> 8 - <span class="font-mono overflow-x">{{ .Secret }}</span> 9 - </div> 10 - {{ end }}
+23 -8
appview/pages/templates/knots/index.html
··· 8 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 9 <div class="flex flex-col gap-6"> 10 10 {{ block "about" . }} {{ end }} 11 - {{ template "knots/fragments/knotListingFull" . }} 11 + {{ block "list" . }} {{ end }} 12 12 {{ block "register" . }} {{ end }} 13 13 </div> 14 14 </section> ··· 27 27 </section> 28 28 {{ end }} 29 29 30 + {{ define "list" }} 31 + <section class="rounded w-full flex flex-col gap-2"> 32 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 33 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 34 + {{ range $registration := .Registrations }} 35 + {{ template "knots/fragments/knotListing" . }} 36 + {{ else }} 37 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 38 + no knots registered yet 39 + </div> 40 + {{ end }} 41 + </div> 42 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 43 + </section> 44 + {{ end }} 45 + 30 46 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 47 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 32 48 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 33 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 49 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 34 50 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 51 + hx-post="/knots/register" 52 + class="max-w-2xl mb-2 space-y-4" 37 53 hx-indicator="#register-button" 38 54 hx-swap="none" 39 55 > ··· 53 69 > 54 70 <span class="inline-flex items-center gap-2"> 55 71 {{ i "plus" "w-4 h-4" }} 56 - generate 72 + register 57 73 </span> 58 74 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 75 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 77 </button> 62 78 </div> 63 79 64 - <div id="registration-error" class="error dark:text-red-400"></div> 80 + <div id="register-error" class="error dark:text-red-400"></div> 65 81 </form> 66 82 67 - <div id="secret"></div> 68 83 </section> 69 84 {{ end }}
+7
appview/pages/templates/layouts/topbar.html
··· 21 21 </div> 22 22 </div> 23 23 </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 24 31 {{ end }} 25 32 26 33 {{ define "newButton" }}
+8 -2
appview/pages/templates/repo/fork.html
··· 5 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 9 <fieldset class="space-y-3"> 10 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 11 <div class="space-y-2"> ··· 30 30 </fieldset> 31 31 32 32 <div class="space-y-2"> 33 - <button type="submit" class="btn">fork repo</button> 33 + <button type="submit" class="btn-create flex items-center gap-2"> 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork repo 36 + <span id="spinner" class="group"> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </span> 39 + </button> 34 40 <div id="repo" class="error"></div> 35 41 </div> 36 42 </form>
-33
appview/pages/templates/repo/index.html
··· 84 84 </optgroup> 85 85 </select> 86 86 <div class="flex items-center gap-2"> 87 - {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 88 - {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 89 - {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 90 - {{ $disabled := "" }} 91 - {{ $title := "" }} 92 - {{ if eq .ForkInfo.Status 0 }} 93 - {{ $disabled = "disabled" }} 94 - {{ $title = "This branch is not behind the upstream" }} 95 - {{ else if eq .ForkInfo.Status 2 }} 96 - {{ $disabled = "disabled" }} 97 - {{ $title = "This branch has conflicts that must be resolved" }} 98 - {{ else if eq .ForkInfo.Status 3 }} 99 - {{ $disabled = "disabled" }} 100 - {{ $title = "This branch does not exist on the upstream" }} 101 - {{ end }} 102 - 103 - <button 104 - id="syncBtn" 105 - {{ $disabled }} 106 - {{ if $title }}title="{{ $title }}"{{ end }} 107 - class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 108 - hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 109 - hx-trigger="click" 110 - hx-swap="none" 111 - > 112 - {{ if $disabled }} 113 - {{ i "refresh-cw-off" "w-4 h-4" }} 114 - {{ else }} 115 - {{ i "refresh-cw" "w-4 h-4" }} 116 - {{ end }} 117 - <span>sync</span> 118 - </button> 119 - {{ end }} 120 87 <a 121 88 href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 122 89 class="btn flex items-center gap-2 no-underline hover:no-underline"
+1 -1
appview/pages/templates/repo/new.html
··· 63 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 64 {{ i "book-plus" "w-4 h-4" }} 65 65 create repo 66 - <span id="create-pull-spinner" class="group"> 66 + <span id="spinner" class="group"> 67 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 68 </span> 69 69 </button>
+3 -1
appview/pages/templates/repo/settings/general.html
··· 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 10 {{ template "deleteRepo" . }} 11 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 11 12 </div> 12 13 </section> 13 14 {{ end }} ··· 22 23 unless you specify a different branch. 23 24 </p> 24 25 </div> 25 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 27 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 27 28 <option value="" disabled selected > 28 29 Choose a default branch ··· 54 55 <button 55 56 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 57 type="button" 58 + hx-swap="none" 57 59 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 60 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 61 {{ i "trash-2" "size-4" }}
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addMemberPopover" . }} {{ end }} 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 20 20 21 - {{ define "addMemberPopover" }} 21 + {{ define "addSpindleMemberPopover" }} 22 22 <form 23 23 hx-post="/spindles/{{ .Instance }}/add" 24 24 hx-indicator="#spinner"
+11 -9
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 1 {{ define "spindles/fragments/spindleListing" }} 2 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "leftSide" . }} {{ end }} 4 - {{ block "rightSide" . }} {{ end }} 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 5 </div> 6 6 {{ end }} 7 7 8 - {{ define "leftSide" }} 8 + {{ define "spindleLeftSide" }} 9 9 {{ if .Verified }} 10 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 - {{ .Instance }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 13 15 <span class="text-gray-500"> 14 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 17 </span> ··· 25 27 {{ end }} 26 28 {{ end }} 27 29 28 - {{ define "rightSide" }} 30 + {{ define "spindleRightSide" }} 29 31 <div id="right-side" class="flex gap-2"> 30 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 31 33 {{ if .Verified }} ··· 33 35 {{ template "spindles/fragments/addMemberModal" . }} 34 36 {{ else }} 35 37 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 36 - {{ block "retryButton" . }} {{ end }} 38 + {{ block "spindleRetryButton" . }} {{ end }} 37 39 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 40 + {{ block "spindleDeleteButton" . }} {{ end }} 39 41 </div> 40 42 {{ end }} 41 43 42 - {{ define "deleteButton" }} 44 + {{ define "spindleDeleteButton" }} 43 45 <button 44 46 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 47 title="Delete spindle" ··· 55 57 {{ end }} 56 58 57 59 58 - {{ define "retryButton" }} 60 + {{ define "spindleRetryButton" }} 59 61 <button 60 62 class="btn gap-2 group" 61 63 title="Retry spindle verification"
+10 -10
appview/pages/templates/user/settings/profile.html
··· 30 30 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 31 <div class="flex items-center justify-between p-4"> 32 32 <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 + <span>Handle</span> 35 + </div> 33 36 {{ if .LoggedInUser.Handle }} 34 37 <span class="font-bold"> 35 38 @{{ .LoggedInUser.Handle }} 36 39 </span> 37 - <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 38 - <span>Handle</span> 39 - </div> 40 40 {{ end }} 41 41 </div> 42 42 </div> 43 43 <div class="flex items-center justify-between p-4"> 44 44 <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 - <span class="font-mono text-xs"> 46 - {{ .LoggedInUser.Did }} 47 - </span> 48 - <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 45 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 49 46 <span>Decentralized Identifier (DID)</span> 50 47 </div> 48 + <span class="font-mono font-bold"> 49 + {{ .LoggedInUser.Did }} 50 + </span> 51 51 </div> 52 52 </div> 53 53 <div class="flex items-center justify-between p-4"> 54 54 <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 56 + <span>Personal Data Server (PDS)</span> 57 + </div> 55 58 <span class="font-bold"> 56 59 {{ .LoggedInUser.Pds }} 57 60 </span> 58 - <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 59 - <span>Personal Data Server (PDS)</span> 60 - </div> 61 61 </div> 62 62 </div> 63 63 </div>
+113 -92
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "log" 10 8 "net/http" 11 9 "sort" ··· 21 19 "tangled.sh/tangled.sh/core/appview/pages" 22 20 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 23 "tangled.sh/tangled.sh/core/idresolver" 25 24 "tangled.sh/tangled.sh/core/knotclient" 26 25 "tangled.sh/tangled.sh/core/patchutil" ··· 30 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 31 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 33 33 "github.com/go-chi/chi/v5" 34 34 "github.com/google/uuid" 35 35 ) ··· 96 96 return 97 97 } 98 98 99 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 99 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 100 resubmitResult := pages.Unknown 101 101 if user.Did == pull.OwnerDid { 102 102 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 151 151 } 152 152 } 153 153 154 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 155 resubmitResult := pages.Unknown 156 156 if user != nil && user.Did == pull.OwnerDid { 157 157 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 215 215 }) 216 216 } 217 217 218 - func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 218 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 219 if pull.State == db.PullMerged { 220 220 return types.MergeCheckResponse{} 221 221 } 222 222 223 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 224 - if err != nil { 225 - log.Printf("failed to get registration key: %v", err) 226 - return types.MergeCheckResponse{ 227 - Error: "failed to check merge status: this knot is unregistered", 228 - } 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 229 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 230 228 231 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 232 - if err != nil { 233 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 234 - return types.MergeCheckResponse{ 235 - Error: "failed to check merge status", 236 - } 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 237 231 } 238 232 239 233 patch := pull.LatestPatch() ··· 246 240 patch = mergeable.CombinedPatch() 247 241 } 248 242 249 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch) 250 - if err != nil { 251 - log.Println("failed to check for mergeability:", err) 243 + resp, xe := tangled.RepoMergeCheck( 244 + r.Context(), 245 + &xrpcc, 246 + &tangled.RepoMergeCheck_Input{ 247 + Did: f.OwnerDid(), 248 + Name: f.Name, 249 + Branch: pull.TargetBranch, 250 + Patch: patch, 251 + }, 252 + ) 253 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 + log.Println("failed to check for mergeability", "err", err) 252 255 return types.MergeCheckResponse{ 253 - Error: "failed to check merge status", 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 254 257 } 255 258 } 256 - switch resp.StatusCode { 257 - case 404: 258 - return types.MergeCheckResponse{ 259 - Error: "failed to check merge status: this knot does not support PRs", 260 - } 261 - case 400: 262 - return types.MergeCheckResponse{ 263 - Error: "failed to check merge status: does this knot support PRs?", 259 + 260 + // convert xrpc response to internal types 261 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 + for i, conflict := range resp.Conflicts { 263 + conflicts[i] = types.ConflictInfo{ 264 + Filename: conflict.Filename, 265 + Reason: conflict.Reason, 264 266 } 265 267 } 266 268 267 - respBody, err := io.ReadAll(resp.Body) 268 - if err != nil { 269 - log.Println("failed to read merge check response body") 270 - return types.MergeCheckResponse{ 271 - Error: "failed to check merge status: knot is not speaking the right language", 272 - } 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 272 + } 273 + 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 273 276 } 274 - defer resp.Body.Close() 275 277 276 - var mergeCheckResponse types.MergeCheckResponse 277 - err = json.Unmarshal(respBody, &mergeCheckResponse) 278 - if err != nil { 279 - log.Println("failed to unmarshal merge check response", err) 280 - return types.MergeCheckResponse{ 281 - Error: "failed to check merge status: knot is not speaking the right language", 282 - } 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 283 280 } 284 281 285 - return mergeCheckResponse 282 + return result 286 283 } 287 284 288 285 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 867 864 return 868 865 } 869 866 870 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 871 - if err != nil { 872 - log.Println("failed to fetch registration key:", err) 873 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 874 - return 875 - } 876 - 877 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 867 + client, err := s.oauth.ServiceClient( 868 + r, 869 + oauth.WithService(fork.Knot), 870 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 871 + oauth.WithDev(s.config.Core.Dev), 872 + ) 878 873 if err != nil { 879 - log.Println("failed to create signed client:", err) 874 + log.Printf("failed to connect to knot server: %v", err) 880 875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 881 876 return 882 877 } ··· 888 883 return 889 884 } 890 885 891 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 892 - if err != nil { 893 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 894 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 886 + resp, err := tangled.RepoHiddenRef( 887 + r.Context(), 888 + client, 889 + &tangled.RepoHiddenRef_Input{ 890 + ForkRef: sourceBranch, 891 + RemoteRef: targetBranch, 892 + Repo: fork.RepoAt().String(), 893 + }, 894 + ) 895 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 896 + s.pages.Notice(w, "pull", err.Error()) 895 897 return 896 898 } 897 899 898 - switch resp.StatusCode { 899 - case 404: 900 - case 400: 901 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 900 + if !resp.Success { 901 + errorMsg := "Failed to create pull request" 902 + if resp.Error != nil { 903 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 904 + } 905 + s.pages.Notice(w, "pull", errorMsg) 902 906 return 903 907 } 904 908 ··· 1464 1468 return 1465 1469 } 1466 1470 1467 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1471 + // update the hidden tracking branch to latest 1472 + client, err := s.oauth.ServiceClient( 1473 + r, 1474 + oauth.WithService(forkRepo.Knot), 1475 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1476 + oauth.WithDev(s.config.Core.Dev), 1477 + ) 1468 1478 if err != nil { 1469 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1470 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1479 + log.Printf("failed to connect to knot server: %v", err) 1471 1480 return 1472 1481 } 1473 1482 1474 - // update the hidden tracking branch to latest 1475 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1476 - if err != nil { 1477 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1478 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1483 + resp, err := tangled.RepoHiddenRef( 1484 + r.Context(), 1485 + client, 1486 + &tangled.RepoHiddenRef_Input{ 1487 + ForkRef: pull.PullSource.Branch, 1488 + RemoteRef: pull.TargetBranch, 1489 + Repo: forkRepo.RepoAt().String(), 1490 + }, 1491 + ) 1492 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1493 + s.pages.Notice(w, "resubmit-error", err.Error()) 1479 1494 return 1480 1495 } 1481 - 1482 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1483 - if err != nil || resp.StatusCode != http.StatusNoContent { 1484 - log.Printf("failed to update tracking branch: %s", err) 1485 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1496 + if !resp.Success { 1497 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1498 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1486 1499 return 1487 1500 } 1488 1501 ··· 1908 1921 1909 1922 patch := pullsToMerge.CombinedPatch() 1910 1923 1911 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1912 - if err != nil { 1913 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1914 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1915 - return 1916 - } 1917 - 1918 1924 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1919 1925 if err != nil { 1920 1926 log.Printf("resolving identity: %s", err) ··· 1927 1933 log.Printf("failed to get primary email: %s", err) 1928 1934 } 1929 1935 1930 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1931 - if err != nil { 1932 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1933 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1934 - return 1936 + authorName := ident.Handle.String() 1937 + mergeInput := &tangled.RepoMerge_Input{ 1938 + Did: f.OwnerDid(), 1939 + Name: f.Name, 1940 + Branch: pull.TargetBranch, 1941 + Patch: patch, 1942 + CommitMessage: &pull.Title, 1943 + AuthorName: &authorName, 1935 1944 } 1936 1945 1937 - // Merge the pull request 1938 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1946 + if pull.Body != "" { 1947 + mergeInput.CommitBody = &pull.Body 1948 + } 1949 + 1950 + if email.Address != "" { 1951 + mergeInput.AuthorEmail = &email.Address 1952 + } 1953 + 1954 + client, err := s.oauth.ServiceClient( 1955 + r, 1956 + oauth.WithService(f.Knot), 1957 + oauth.WithLxm(tangled.RepoMergeNSID), 1958 + oauth.WithDev(s.config.Core.Dev), 1959 + ) 1939 1960 if err != nil { 1940 - log.Printf("failed to merge pull request: %s", err) 1961 + log.Printf("failed to connect to knot server: %v", err) 1941 1962 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1942 1963 return 1943 1964 } 1944 1965 1945 - if resp.StatusCode != http.StatusOK { 1946 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1947 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1966 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 1967 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1968 + s.pages.Notice(w, "pull-merge-error", err.Error()) 1948 1969 return 1949 1970 } 1950 1971
+10 -100
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "encoding/json" 5 - "fmt" 6 4 "log" 7 5 "net/http" 8 6 "slices" ··· 11 9 12 10 "tangled.sh/tangled.sh/core/appview/commitverify" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 12 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 14 "tangled.sh/tangled.sh/core/knotclient" 19 15 "tangled.sh/tangled.sh/core/types" ··· 105 101 user := rp.oauth.GetUser(r) 106 102 repoInfo := f.RepoInfo(user) 107 103 108 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 109 - if err != nil { 110 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 111 - rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 112 - } 113 - 114 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 115 - if err != nil { 116 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 117 - return 118 - } 119 - 120 - var forkInfo *types.ForkInfo 121 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 122 - forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient) 123 - if err != nil { 124 - log.Printf("Failed to fetch fork information: %v", err) 125 - return 126 - } 127 - } 128 - 129 104 // TODO: a bit dirty 130 - languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "") 105 + languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 131 106 if err != nil { 132 107 log.Printf("failed to compute language percentages: %s", err) 133 108 // non-fatal ··· 144 119 } 145 120 146 121 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 147 - LoggedInUser: user, 148 - RepoInfo: repoInfo, 149 - TagMap: tagMap, 150 - RepoIndexResponse: *result, 151 - CommitsTrunc: commitsTrunc, 152 - TagsTrunc: tagsTrunc, 153 - ForkInfo: forkInfo, 122 + LoggedInUser: user, 123 + RepoInfo: repoInfo, 124 + TagMap: tagMap, 125 + RepoIndexResponse: *result, 126 + CommitsTrunc: commitsTrunc, 127 + TagsTrunc: tagsTrunc, 128 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 154 129 BranchesTrunc: branchesTrunc, 155 130 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 156 131 VerifiedCommits: vc, ··· 161 136 162 137 func (rp *Repo) getLanguageInfo( 163 138 f *reporesolver.ResolvedRepo, 164 - signedClient *knotclient.SignedClient, 139 + us *knotclient.UnsignedClient, 165 140 currentRef string, 166 141 isDefaultRef bool, 167 142 ) ([]types.RepoLanguageDetails, error) { ··· 174 149 175 150 if err != nil || langs == nil { 176 151 // non-fatal, fetch langs from ks 177 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 152 + ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 178 153 if err != nil { 179 154 return nil, err 180 155 } ··· 231 206 232 207 return languageStats, nil 233 208 } 234 - 235 - func getForkInfo( 236 - repoInfo repoinfo.RepoInfo, 237 - rp *Repo, 238 - f *reporesolver.ResolvedRepo, 239 - currentRef string, 240 - user *oauth.User, 241 - signedClient *knotclient.SignedClient, 242 - ) (*types.ForkInfo, error) { 243 - if user == nil { 244 - return nil, nil 245 - } 246 - 247 - forkInfo := types.ForkInfo{ 248 - IsFork: repoInfo.Source != nil, 249 - Status: types.UpToDate, 250 - } 251 - 252 - if !forkInfo.IsFork { 253 - forkInfo.IsFork = false 254 - return &forkInfo, nil 255 - } 256 - 257 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 258 - if err != nil { 259 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 260 - return nil, err 261 - } 262 - 263 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 264 - if err != nil { 265 - log.Println("failed to reach knotserver", err) 266 - return nil, err 267 - } 268 - 269 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 270 - return branch.Name == currentRef 271 - }) { 272 - forkInfo.Status = types.MissingBranch 273 - return &forkInfo, nil 274 - } 275 - 276 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef) 277 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 278 - log.Printf("failed to update tracking branch: %s", err) 279 - return nil, err 280 - } 281 - 282 - hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef) 283 - 284 - var status types.AncestorCheckResponse 285 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef) 286 - if err != nil { 287 - log.Printf("failed to check if fork is ahead/behind: %s", err) 288 - return nil, err 289 - } 290 - 291 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 292 - log.Printf("failed to decode fork status: %s", err) 293 - return nil, err 294 - } 295 - 296 - forkInfo.Status = status.Status 297 - return &forkInfo, nil 298 - }
+194 -203
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 20 22 "tangled.sh/tangled.sh/core/api/tangled" 21 23 "tangled.sh/tangled.sh/core/appview/commitverify" 22 24 "tangled.sh/tangled.sh/core/appview/config" ··· 26 28 "tangled.sh/tangled.sh/core/appview/pages" 27 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 32 "tangled.sh/tangled.sh/core/eventconsumer" 30 33 "tangled.sh/tangled.sh/core/idresolver" 31 34 "tangled.sh/tangled.sh/core/knotclient" ··· 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 35 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 36 40 37 41 securejoin "github.com/cyphar/filepath-securejoin" 38 42 "github.com/go-chi/chi/v5" 39 43 "github.com/go-git/go-git/v5/plumbing" 40 44 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 45 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 46 ) 45 47 46 48 type Repo struct { ··· 54 56 enforcer *rbac.Enforcer 55 57 notifier notify.Notifier 56 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 57 60 } 58 61 59 62 func New( ··· 860 863 fail("Failed to write record to PDS.", err) 861 864 return 862 865 } 863 - l = l.With("at-uri", resp.Uri) 866 + 867 + aturi := resp.Uri 868 + l = l.With("at-uri", aturi) 864 869 l.Info("wrote record to PDS") 865 870 866 - l.Info("adding to knot") 867 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 871 + tx, err := rp.db.BeginTx(r.Context(), nil) 868 872 if err != nil { 869 - fail("Failed to add to knot.", err) 873 + fail("Failed to add collaborator.", err) 870 874 return 871 875 } 872 876 873 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 874 - if err != nil { 875 - fail("Failed to add to knot.", err) 876 - return 877 - } 877 + rollback := func() { 878 + err1 := tx.Rollback() 879 + err2 := rp.enforcer.E.LoadPolicy() 880 + err3 := rollbackRecord(context.Background(), aturi, client) 878 881 879 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 880 - if err != nil { 881 - fail("Knot was unreachable.", err) 882 - return 883 - } 882 + // ignore txn complete errors, this is okay 883 + if errors.Is(err1, sql.ErrTxDone) { 884 + err1 = nil 885 + } 884 886 885 - if ksResp.StatusCode != http.StatusNoContent { 886 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 887 - return 887 + if errs := errors.Join(err1, err2, err3); errs != nil { 888 + l.Error("failed to rollback changes", "errs", errs) 889 + return 890 + } 888 891 } 889 - 890 - tx, err := rp.db.BeginTx(r.Context(), nil) 891 - if err != nil { 892 - fail("Failed to add collaborator.", err) 893 - return 894 - } 895 - defer func() { 896 - tx.Rollback() 897 - err = rp.enforcer.E.LoadPolicy() 898 - if err != nil { 899 - fail("Failed to add collaborator.", err) 900 - } 901 - }() 892 + defer rollback() 902 893 903 894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 904 895 if err != nil { ··· 930 921 return 931 922 } 932 923 924 + // clear aturi to when everything is successful 925 + aturi = "" 926 + 933 927 rp.pages.HxRefresh(w) 934 928 } 935 929 936 930 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 937 931 user := rp.oauth.GetUser(r) 938 932 933 + noticeId := "operation-error" 939 934 f, err := rp.repoResolver.Resolve(r) 940 935 if err != nil { 941 936 log.Println("failed to get repo and knot", err) ··· 955 950 }) 956 951 if err != nil { 957 952 log.Printf("failed to delete record: %s", err) 958 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 953 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 959 954 return 960 955 } 961 956 log.Println("removed repo record ", f.RepoAt().String()) 962 957 963 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 958 + client, err := rp.oauth.ServiceClient( 959 + r, 960 + oauth.WithService(f.Knot), 961 + oauth.WithLxm(tangled.RepoDeleteNSID), 962 + oauth.WithDev(rp.config.Core.Dev), 963 + ) 964 964 if err != nil { 965 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 965 + log.Println("failed to connect to knot server:", err) 966 966 return 967 967 } 968 968 969 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 970 - if err != nil { 971 - log.Println("failed to create client to ", f.Knot) 969 + err = tangled.RepoDelete( 970 + r.Context(), 971 + client, 972 + &tangled.RepoDelete_Input{ 973 + Did: f.OwnerDid(), 974 + Name: f.Name, 975 + Rkey: f.Rkey, 976 + }, 977 + ) 978 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 979 + rp.pages.Notice(w, noticeId, err.Error()) 972 980 return 973 981 } 974 - 975 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 976 - if err != nil { 977 - log.Printf("failed to make request to %s: %s", f.Knot, err) 978 - return 979 - } 980 - 981 - if ksResp.StatusCode != http.StatusNoContent { 982 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 983 - } else { 984 - log.Println("removed repo from knot ", f.Knot) 985 - } 982 + log.Println("deleted repo from knot") 986 983 987 984 tx, err := rp.db.BeginTx(r.Context(), nil) 988 985 if err != nil { ··· 1001 998 // remove collaborator RBAC 1002 999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1003 1000 if err != nil { 1004 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1001 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 1005 1002 return 1006 1003 } 1007 1004 for _, c := range repoCollaborators { ··· 1013 1010 // remove repo RBAC 1014 1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1015 1012 if err != nil { 1016 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1013 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1017 1014 return 1018 1015 } 1019 1016 1020 1017 // remove repo from db 1021 1018 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 1022 1019 if err != nil { 1023 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1020 + rp.pages.Notice(w, noticeId, "Failed to update appview") 1024 1021 return 1025 1022 } 1026 1023 log.Println("removed repo from db") ··· 1049 1046 return 1050 1047 } 1051 1048 1049 + noticeId := "operation-error" 1052 1050 branch := r.FormValue("branch") 1053 1051 if branch == "" { 1054 1052 http.Error(w, "malformed form", http.StatusBadRequest) 1055 1053 return 1056 1054 } 1057 1055 1058 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1059 - if err != nil { 1060 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1061 - return 1062 - } 1063 - 1064 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1065 - if err != nil { 1066 - log.Println("failed to create client to ", f.Knot) 1067 - return 1068 - } 1069 - 1070 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 1056 + client, err := rp.oauth.ServiceClient( 1057 + r, 1058 + oauth.WithService(f.Knot), 1059 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1060 + oauth.WithDev(rp.config.Core.Dev), 1061 + ) 1071 1062 if err != nil { 1072 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1063 + log.Println("failed to connect to knot server:", err) 1064 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1073 1065 return 1074 1066 } 1075 1067 1076 - if ksResp.StatusCode != http.StatusNoContent { 1077 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1068 + xe := tangled.RepoSetDefaultBranch( 1069 + r.Context(), 1070 + client, 1071 + &tangled.RepoSetDefaultBranch_Input{ 1072 + Repo: f.RepoAt().String(), 1073 + DefaultBranch: branch, 1074 + }, 1075 + ) 1076 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1077 + log.Println("xrpc failed", "err", xe) 1078 + rp.pages.Notice(w, noticeId, err.Error()) 1078 1079 return 1079 1080 } 1080 1081 1081 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1082 + rp.pages.HxRefresh(w) 1082 1083 } 1083 1084 1084 1085 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1194 1195 case "pipelines": 1195 1196 rp.pipelineSettings(w, r) 1196 1197 } 1197 - 1198 - // user := rp.oauth.GetUser(r) 1199 - // repoCollaborators, err := f.Collaborators(r.Context()) 1200 - // if err != nil { 1201 - // log.Println("failed to get collaborators", err) 1202 - // } 1203 - 1204 - // isCollaboratorInviteAllowed := false 1205 - // if user != nil { 1206 - // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1207 - // if err == nil && ok { 1208 - // isCollaboratorInviteAllowed = true 1209 - // } 1210 - // } 1211 - 1212 - // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1213 - // if err != nil { 1214 - // log.Println("failed to create unsigned client", err) 1215 - // return 1216 - // } 1217 - 1218 - // result, err := us.Branches(f.OwnerDid(), f.Name) 1219 - // if err != nil { 1220 - // log.Println("failed to reach knotserver", err) 1221 - // return 1222 - // } 1223 - 1224 - // // all spindles that this user is a member of 1225 - // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1226 - // if err != nil { 1227 - // log.Println("failed to fetch spindles", err) 1228 - // return 1229 - // } 1230 - 1231 - // var secrets []*tangled.RepoListSecrets_Secret 1232 - // if f.Spindle != "" { 1233 - // if spindleClient, err := rp.oauth.ServiceClient( 1234 - // r, 1235 - // oauth.WithService(f.Spindle), 1236 - // oauth.WithLxm(tangled.RepoListSecretsNSID), 1237 - // oauth.WithDev(rp.config.Core.Dev), 1238 - // ); err != nil { 1239 - // log.Println("failed to create spindle client", err) 1240 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1241 - // log.Println("failed to fetch secrets", err) 1242 - // } else { 1243 - // secrets = resp.Secrets 1244 - // } 1245 - // } 1246 - 1247 - // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1248 - // LoggedInUser: user, 1249 - // RepoInfo: f.RepoInfo(user), 1250 - // Collaborators: repoCollaborators, 1251 - // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1252 - // Branches: result.Branches, 1253 - // Spindles: spindles, 1254 - // CurrentSpindle: f.Spindle, 1255 - // Secrets: secrets, 1256 - // }) 1257 1198 } 1258 1199 1259 1200 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1373 1314 1374 1315 switch r.Method { 1375 1316 case http.MethodPost: 1376 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1317 + client, err := rp.oauth.ServiceClient( 1318 + r, 1319 + oauth.WithService(f.Knot), 1320 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1321 + oauth.WithDev(rp.config.Core.Dev), 1322 + ) 1377 1323 if err != nil { 1378 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1324 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1379 1325 return 1380 1326 } 1381 1327 1382 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1383 - if err != nil { 1384 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1328 + repoInfo := f.RepoInfo(user) 1329 + if repoInfo.Source == nil { 1330 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1385 1331 return 1386 1332 } 1387 1333 1388 - var uri string 1389 - if rp.config.Core.Dev { 1390 - uri = "http" 1391 - } else { 1392 - uri = "https" 1393 - } 1394 - forkName := fmt.Sprintf("%s", f.Name) 1395 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1396 - 1397 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1398 - if err != nil { 1399 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1334 + err = tangled.RepoForkSync( 1335 + r.Context(), 1336 + client, 1337 + &tangled.RepoForkSync_Input{ 1338 + Did: user.Did, 1339 + Name: f.Name, 1340 + Source: repoInfo.Source.RepoAt().String(), 1341 + Branch: ref, 1342 + }, 1343 + ) 1344 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1345 + rp.pages.Notice(w, "repo", err.Error()) 1400 1346 return 1401 1347 } 1402 1348 ··· 1429 1375 }) 1430 1376 1431 1377 case http.MethodPost: 1378 + l := rp.logger.With("handler", "ForkRepo") 1432 1379 1433 - knot := r.FormValue("knot") 1434 - if knot == "" { 1380 + targetKnot := r.FormValue("knot") 1381 + if targetKnot == "" { 1435 1382 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1436 1383 return 1437 1384 } 1385 + l = l.With("targetKnot", targetKnot) 1438 1386 1439 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1387 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1440 1388 if err != nil || !ok { 1441 1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1442 1390 return 1443 1391 } 1444 1392 1445 - forkName := fmt.Sprintf("%s", f.Name) 1446 - 1393 + // choose a name for a fork 1394 + forkName := f.Name 1447 1395 // this check is *only* to see if the forked repo name already exists 1448 1396 // in the user's account. 1449 1397 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) ··· 1459 1407 // repo with this name already exists, append random string 1460 1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1461 1409 } 1462 - secret, err := db.GetRegistrationKey(rp.db, knot) 1463 - if err != nil { 1464 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1465 - return 1466 - } 1410 + l = l.With("forkName", forkName) 1467 1411 1468 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1469 - if err != nil { 1470 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1471 - return 1472 - } 1473 - 1474 - var uri string 1412 + uri := "https" 1475 1413 if rp.config.Core.Dev { 1476 1414 uri = "http" 1477 - } else { 1478 - uri = "https" 1479 1415 } 1416 + 1480 1417 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1418 + l = l.With("cloneUrl", forkSourceUrl) 1419 + 1481 1420 sourceAt := f.RepoAt().String() 1482 1421 1422 + // create an atproto record for this fork 1483 1423 rkey := tid.TID() 1484 1424 repo := &db.Repo{ 1485 1425 Did: user.Did, 1486 1426 Name: forkName, 1487 - Knot: knot, 1427 + Knot: targetKnot, 1488 1428 Rkey: rkey, 1489 1429 Source: sourceAt, 1490 1430 } 1491 1431 1492 - tx, err := rp.db.BeginTx(r.Context(), nil) 1493 - if err != nil { 1494 - log.Println(err) 1495 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1496 - return 1497 - } 1498 - defer func() { 1499 - tx.Rollback() 1500 - err = rp.enforcer.E.LoadPolicy() 1501 - if err != nil { 1502 - log.Println("failed to rollback policies") 1503 - } 1504 - }() 1505 - 1506 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1507 - if err != nil { 1508 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1509 - return 1510 - } 1511 - 1512 - switch resp.StatusCode { 1513 - case http.StatusConflict: 1514 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1515 - return 1516 - case http.StatusInternalServerError: 1517 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1518 - case http.StatusNoContent: 1519 - // continue 1520 - } 1521 - 1522 1432 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1523 1433 if err != nil { 1524 - log.Println("failed to get authorized client", err) 1525 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1434 + l.Error("failed to create xrpcclient", "err", err) 1435 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1526 1436 return 1527 1437 } 1528 1438 ··· 1541 1451 }}, 1542 1452 }) 1543 1453 if err != nil { 1544 - log.Printf("failed to create record: %s", err) 1454 + l.Error("failed to write to PDS", "err", err) 1545 1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1546 1456 return 1547 1457 } 1548 - log.Println("created repo record: ", atresp.Uri) 1458 + 1459 + aturi := atresp.Uri 1460 + l = l.With("aturi", aturi) 1461 + l.Info("wrote to PDS") 1462 + 1463 + tx, err := rp.db.BeginTx(r.Context(), nil) 1464 + if err != nil { 1465 + l.Info("txn failed", "err", err) 1466 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1467 + return 1468 + } 1469 + 1470 + // The rollback function reverts a few things on failure: 1471 + // - the pending txn 1472 + // - the ACLs 1473 + // - the atproto record created 1474 + rollback := func() { 1475 + err1 := tx.Rollback() 1476 + err2 := rp.enforcer.E.LoadPolicy() 1477 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1478 + 1479 + // ignore txn complete errors, this is okay 1480 + if errors.Is(err1, sql.ErrTxDone) { 1481 + err1 = nil 1482 + } 1483 + 1484 + if errs := errors.Join(err1, err2, err3); errs != nil { 1485 + l.Error("failed to rollback changes", "errs", errs) 1486 + return 1487 + } 1488 + } 1489 + defer rollback() 1490 + 1491 + client, err := rp.oauth.ServiceClient( 1492 + r, 1493 + oauth.WithService(targetKnot), 1494 + oauth.WithLxm(tangled.RepoCreateNSID), 1495 + oauth.WithDev(rp.config.Core.Dev), 1496 + ) 1497 + if err != nil { 1498 + l.Error("could not create service client", "err", err) 1499 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1500 + return 1501 + } 1502 + 1503 + err = tangled.RepoCreate( 1504 + r.Context(), 1505 + client, 1506 + &tangled.RepoCreate_Input{ 1507 + Rkey: rkey, 1508 + Source: &forkSourceUrl, 1509 + }, 1510 + ) 1511 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1512 + rp.pages.Notice(w, "repo", err.Error()) 1513 + return 1514 + } 1549 1515 1550 1516 err = db.AddRepo(tx, repo) 1551 1517 if err != nil { ··· 1556 1522 1557 1523 // acls 1558 1524 p, _ := securejoin.SecureJoin(user.Did, forkName) 1559 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1525 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1560 1526 if err != nil { 1561 1527 log.Println(err) 1562 1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1577 1543 return 1578 1544 } 1579 1545 1546 + // reset the ATURI because the transaction completed successfully 1547 + aturi = "" 1548 + 1549 + rp.notifier.NewRepo(r.Context(), repo) 1580 1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1581 - return 1582 1551 } 1552 + } 1553 + 1554 + // this is used to rollback changes made to the PDS 1555 + // 1556 + // it is a no-op if the provided ATURI is empty 1557 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1558 + if aturi == "" { 1559 + return nil 1560 + } 1561 + 1562 + parsed := syntax.ATURI(aturi) 1563 + 1564 + collection := parsed.Collection().String() 1565 + repo := parsed.Authority().String() 1566 + rkey := parsed.RecordKey().String() 1567 + 1568 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1569 + Collection: collection, 1570 + Repo: repo, 1571 + Rkey: rkey, 1572 + }) 1573 + return err 1583 1574 } 1584 1575 1585 1576 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
+164
appview/serververify/verify.go
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + ) 15 + 16 + var ( 17 + FetchError = errors.New("failed to fetch owner") 18 + ) 19 + 20 + // fetchOwner fetches the owner DID from a server's /owner endpoint 21 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 + scheme := "https" 23 + if dev { 24 + scheme = "http" 25 + } 26 + 27 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 + req, err := http.NewRequest("GET", url, nil) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + client := &http.Client{ 34 + Timeout: 1 * time.Second, 35 + } 36 + 37 + resp, err := client.Do(req.WithContext(ctx)) 38 + if err != nil || resp.StatusCode != 200 { 39 + return "", fmt.Errorf("failed to fetch /owner") 40 + } 41 + 42 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 + if err != nil { 44 + return "", fmt.Errorf("failed to read /owner response: %w", err) 45 + } 46 + 47 + did := strings.TrimSpace(string(body)) 48 + if did == "" { 49 + return "", fmt.Errorf("empty DID in /owner response") 50 + } 51 + 52 + return did, nil 53 + } 54 + 55 + type OwnerMismatch struct { 56 + expected string 57 + observed string 58 + } 59 + 60 + func (e *OwnerMismatch) Error() string { 61 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 + } 63 + 64 + // RunVerification verifies that the server at the given domain has the expected owner 65 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 + observedOwner, err := fetchOwner(ctx, domain, dev) 67 + if err != nil { 68 + return fmt.Errorf("%w: %w", FetchError, err) 69 + } 70 + 71 + if observedOwner != expectedOwner { 72 + return &OwnerMismatch{ 73 + expected: expectedOwner, 74 + observed: observedOwner, 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 82 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 + tx, err := d.Begin() 84 + if err != nil { 85 + return 0, fmt.Errorf("failed to create txn: %w", err) 86 + } 87 + defer func() { 88 + tx.Rollback() 89 + e.E.LoadPolicy() 90 + }() 91 + 92 + // mark this spindle as verified in the db 93 + rowId, err := db.VerifySpindle( 94 + tx, 95 + db.FilterEq("owner", owner), 96 + db.FilterEq("instance", instance), 97 + ) 98 + if err != nil { 99 + return 0, fmt.Errorf("failed to write to DB: %w", err) 100 + } 101 + 102 + err = e.AddSpindleOwner(instance, owner) 103 + if err != nil { 104 + return 0, fmt.Errorf("failed to update ACL: %w", err) 105 + } 106 + 107 + err = tx.Commit() 108 + if err != nil { 109 + return 0, fmt.Errorf("failed to commit txn: %w", err) 110 + } 111 + 112 + err = e.E.SavePolicy() 113 + if err != nil { 114 + return 0, fmt.Errorf("failed to update ACL: %w", err) 115 + } 116 + 117 + return rowId, nil 118 + } 119 + 120 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 121 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 122 + tx, err := d.BeginTx(context.Background(), nil) 123 + if err != nil { 124 + return fmt.Errorf("failed to start tx: %w", err) 125 + } 126 + defer func() { 127 + tx.Rollback() 128 + e.E.LoadPolicy() 129 + }() 130 + 131 + // mark as registered 132 + err = db.MarkRegistered( 133 + tx, 134 + db.FilterEq("did", owner), 135 + db.FilterEq("domain", domain), 136 + ) 137 + if err != nil { 138 + return fmt.Errorf("failed to register domain: %w", err) 139 + } 140 + 141 + // add basic acls for this domain 142 + err = e.AddKnot(domain) 143 + if err != nil { 144 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 145 + } 146 + 147 + // add this did as owner of this domain 148 + err = e.AddKnotOwner(domain, owner) 149 + if err != nil { 150 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 151 + } 152 + 153 + err = tx.Commit() 154 + if err != nil { 155 + return fmt.Errorf("failed to commit changes: %w", err) 156 + } 157 + 158 + err = e.E.SavePolicy() 159 + if err != nil { 160 + return fmt.Errorf("failed to update ACLs: %w", err) 161 + } 162 + 163 + return nil 164 + }
+8 -8
appview/spindles/spindles.go
··· 15 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 17 "tangled.sh/tangled.sh/core/appview/pages" 18 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 19 "tangled.sh/tangled.sh/core/idresolver" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 "tangled.sh/tangled.sh/core/tid" ··· 227 227 } 228 228 229 229 // begin verification 230 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 230 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 231 231 if err != nil { 232 232 l.Error("verification failed", "err", err) 233 233 s.Pages.HxRefresh(w) 234 234 return 235 235 } 236 236 237 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 237 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 238 238 if err != nil { 239 239 l.Error("failed to mark verified", "err", err) 240 240 s.Pages.HxRefresh(w) ··· 400 400 } 401 401 402 402 // begin verification 403 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 403 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 404 404 if err != nil { 405 405 l.Error("verification failed", "err", err) 406 406 407 - if errors.Is(err, verify.FetchError) { 408 - s.Pages.Notice(w, noticeId, err.Error()) 407 + if errors.Is(err, serververify.FetchError) { 408 + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 409 409 return 410 410 } 411 411 412 - if e, ok := err.(*verify.OwnerMismatch); ok { 412 + if e, ok := err.(*serververify.OwnerMismatch); ok { 413 413 s.Pages.Notice(w, noticeId, e.Error()) 414 414 return 415 415 } ··· 418 418 return 419 419 } 420 420 421 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 421 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 422 422 if err != nil { 423 423 l.Error("failed to mark verified", "err", err) 424 424 s.Pages.Notice(w, noticeId, err.Error())
-118
appview/spindleverify/verify.go
··· 1 - package spindleverify 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 - 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - ) 15 - 16 - var ( 17 - FetchError = errors.New("failed to fetch owner") 18 - ) 19 - 20 - // TODO: move this to "spindleclient" or similar 21 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 - scheme := "https" 23 - if dev { 24 - scheme = "http" 25 - } 26 - 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 - } 41 - 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 - } 46 - 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 - } 54 - 55 - type OwnerMismatch struct { 56 - expected string 57 - observed string 58 - } 59 - 60 - func (e *OwnerMismatch) Error() string { 61 - return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 - } 63 - 64 - func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 - // begin verification 66 - observedOwner, err := fetchOwner(ctx, instance, dev) 67 - if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 - } 70 - 71 - if observedOwner != expectedOwner { 72 - return &OwnerMismatch{ 73 - expected: expectedOwner, 74 - observed: observedOwner, 75 - } 76 - } 77 - 78 - return nil 79 - } 80 - 81 - // mark this spindle as verified in the DB and add this user as its owner 82 - func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 - tx, err := d.Begin() 84 - if err != nil { 85 - return 0, fmt.Errorf("failed to create txn: %w", err) 86 - } 87 - defer func() { 88 - tx.Rollback() 89 - e.E.LoadPolicy() 90 - }() 91 - 92 - // mark this spindle as verified in the db 93 - rowId, err := db.VerifySpindle( 94 - tx, 95 - db.FilterEq("owner", owner), 96 - db.FilterEq("instance", instance), 97 - ) 98 - if err != nil { 99 - return 0, fmt.Errorf("failed to write to DB: %w", err) 100 - } 101 - 102 - err = e.AddSpindleOwner(instance, owner) 103 - if err != nil { 104 - return 0, fmt.Errorf("failed to update ACL: %w", err) 105 - } 106 - 107 - err = tx.Commit() 108 - if err != nil { 109 - return 0, fmt.Errorf("failed to commit txn: %w", err) 110 - } 111 - 112 - err = e.E.SavePolicy() 113 - if err != nil { 114 - return 0, fmt.Errorf("failed to update ACL: %w", err) 115 - } 116 - 117 - return rowId, nil 118 - }
+5 -2
appview/state/knotstream.go
··· 24 24 ) 25 25 26 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetCompletedRegistrations(d) 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 28 31 if err != nil { 29 32 return nil, err 30 33 } 31 34 32 35 srcs := make(map[ec.Source]struct{}) 33 36 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 37 + s := ec.NewKnotSource(k.Domain) 35 38 srcs[s] = struct{}{} 36 39 } 37 40
+3 -3
appview/state/router.go
··· 147 147 148 148 r.Mount("/settings", s.SettingsRouter()) 149 149 r.Mount("/strings", s.StringsRouter(mw)) 150 - r.Mount("/knots", s.KnotsRouter(mw)) 150 + r.Mount("/knots", s.KnotsRouter()) 151 151 r.Mount("/spindles", s.SpindlesRouter()) 152 152 r.Mount("/signup", s.SignupRouter()) 153 153 r.Mount("/", s.OAuthRouter()) ··· 195 195 return spindles.Router() 196 196 } 197 197 198 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 198 + func (s *State) KnotsRouter() http.Handler { 199 199 logger := log.New("knots") 200 200 201 201 knots := &knots.Knots{ ··· 209 209 Logger: logger, 210 210 } 211 211 212 - return knots.Router(mw) 212 + return knots.Router() 213 213 } 214 214 215 215 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+95 -39
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 6 + "errors" 5 7 "fmt" 6 8 "log" 7 9 "log/slog" ··· 10 12 "time" 11 13 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 13 16 lexutil "github.com/bluesky-social/indigo/lex/util" 14 17 securejoin "github.com/cyphar/filepath-securejoin" 15 18 "github.com/go-chi/chi/v5" ··· 25 28 "tangled.sh/tangled.sh/core/appview/pages" 26 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 28 32 "tangled.sh/tangled.sh/core/eventconsumer" 29 33 "tangled.sh/tangled.sh/core/idresolver" 30 34 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 35 tlog "tangled.sh/tangled.sh/core/log" 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 38 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 35 39 ) 36 40 37 41 type State struct { ··· 48 52 repoResolver *reporesolver.RepoResolver 49 53 knotstream *eventconsumer.Consumer 50 54 spindlestream *eventconsumer.Consumer 55 + logger *slog.Logger 51 56 } 52 57 53 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 152 157 repoResolver, 153 158 knotstream, 154 159 spindlestream, 160 + slog.Default(), 155 161 } 156 162 157 163 return state, nil ··· 291 297 }) 292 298 293 299 case http.MethodPost: 300 + l := s.logger.With("handler", "NewRepo") 301 + 294 302 user := s.oauth.GetUser(r) 303 + l = l.With("did", user.Did) 304 + l = l.With("handle", user.Handle) 295 305 306 + // form validation 296 307 domain := r.FormValue("domain") 297 308 if domain == "" { 298 309 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 299 310 return 300 311 } 312 + l = l.With("knot", domain) 301 313 302 314 repoName := r.FormValue("name") 303 315 if repoName == "" { ··· 309 321 s.pages.Notice(w, "repo", err.Error()) 310 322 return 311 323 } 312 - 313 324 repoName = stripGitExt(repoName) 325 + l = l.With("repoName", repoName) 314 326 315 327 defaultBranch := r.FormValue("branch") 316 328 if defaultBranch == "" { 317 329 defaultBranch = "main" 318 330 } 331 + l = l.With("defaultBranch", defaultBranch) 319 332 320 333 description := r.FormValue("description") 321 334 335 + // ACL validation 322 336 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 323 337 if err != nil || !ok { 338 + l.Info("unauthorized") 324 339 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 325 340 return 326 341 } 327 342 343 + // Check for existing repos 328 344 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 329 345 if err == nil && existingRepo != nil { 330 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 346 + l.Info("repo exists") 347 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 331 348 return 332 349 } 333 350 334 - secret, err := db.GetRegistrationKey(s.db, domain) 335 - if err != nil { 336 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 337 - return 338 - } 339 - 340 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 341 - if err != nil { 342 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 343 - return 344 - } 345 - 351 + // create atproto record for this repo 346 352 rkey := tid.TID() 347 353 repo := &db.Repo{ 348 354 Did: user.Did, ··· 354 360 355 361 xrpcClient, err := s.oauth.AuthorizedClient(r) 356 362 if err != nil { 363 + l.Info("PDS write failed", "err", err) 357 364 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 358 365 return 359 366 } ··· 372 379 }}, 373 380 }) 374 381 if err != nil { 375 - log.Printf("failed to create record: %s", err) 382 + l.Info("PDS write failed", "err", err) 376 383 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 377 384 return 378 385 } 379 - log.Println("created repo record: ", atresp.Uri) 386 + 387 + aturi := atresp.Uri 388 + l = l.With("aturi", aturi) 389 + l.Info("wrote to PDS") 380 390 381 391 tx, err := s.db.BeginTx(r.Context(), nil) 382 392 if err != nil { 383 - log.Println(err) 393 + l.Info("txn failed", "err", err) 384 394 s.pages.Notice(w, "repo", "Failed to save repository information.") 385 395 return 386 396 } 387 - defer func() { 388 - tx.Rollback() 389 - err = s.enforcer.E.LoadPolicy() 390 - if err != nil { 391 - log.Println("failed to rollback policies") 397 + 398 + // The rollback function reverts a few things on failure: 399 + // - the pending txn 400 + // - the ACLs 401 + // - the atproto record created 402 + rollback := func() { 403 + err1 := tx.Rollback() 404 + err2 := s.enforcer.E.LoadPolicy() 405 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 406 + 407 + // ignore txn complete errors, this is okay 408 + if errors.Is(err1, sql.ErrTxDone) { 409 + err1 = nil 392 410 } 393 - }() 394 411 395 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 412 + if errs := errors.Join(err1, err2, err3); errs != nil { 413 + l.Error("failed to rollback changes", "errs", errs) 414 + return 415 + } 416 + } 417 + defer rollback() 418 + 419 + client, err := s.oauth.ServiceClient( 420 + r, 421 + oauth.WithService(domain), 422 + oauth.WithLxm(tangled.RepoCreateNSID), 423 + oauth.WithDev(s.config.Core.Dev), 424 + ) 396 425 if err != nil { 397 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 426 + l.Error("service auth failed", "err", err) 427 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 398 428 return 399 429 } 400 430 401 - switch resp.StatusCode { 402 - case http.StatusConflict: 403 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 431 + xe := tangled.RepoCreate( 432 + r.Context(), 433 + client, 434 + &tangled.RepoCreate_Input{ 435 + Rkey: rkey, 436 + }, 437 + ) 438 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 439 + l.Error("xrpc error", "xe", xe) 440 + s.pages.Notice(w, "repo", err.Error()) 404 441 return 405 - case http.StatusInternalServerError: 406 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 407 - case http.StatusNoContent: 408 - // continue 409 442 } 410 443 411 444 err = db.AddRepo(tx, repo) 412 445 if err != nil { 413 - log.Println(err) 446 + l.Error("db write failed", "err", err) 414 447 s.pages.Notice(w, "repo", "Failed to save repository information.") 415 448 return 416 449 } ··· 419 452 p, _ := securejoin.SecureJoin(user.Did, repoName) 420 453 err = s.enforcer.AddRepo(user.Did, domain, p) 421 454 if err != nil { 422 - log.Println(err) 455 + l.Error("acl setup failed", "err", err) 423 456 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 424 457 return 425 458 } 426 459 427 460 err = tx.Commit() 428 461 if err != nil { 429 - log.Println("failed to commit changes", err) 462 + l.Error("txn commit failed", "err", err) 430 463 http.Error(w, err.Error(), http.StatusInternalServerError) 431 464 return 432 465 } 433 466 434 467 err = s.enforcer.E.SavePolicy() 435 468 if err != nil { 436 - log.Println("failed to update ACLs", err) 469 + l.Error("acl save failed", "err", err) 437 470 http.Error(w, err.Error(), http.StatusInternalServerError) 438 471 return 439 472 } 473 + 474 + // reset the ATURI because the transaction completed successfully 475 + aturi = "" 440 476 441 477 s.notifier.NewRepo(r.Context(), repo) 478 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 479 + } 480 + } 442 481 443 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 444 - return 482 + // this is used to rollback changes made to the PDS 483 + // 484 + // it is a no-op if the provided ATURI is empty 485 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 486 + if aturi == "" { 487 + return nil 445 488 } 489 + 490 + parsed := syntax.ATURI(aturi) 491 + 492 + collection := parsed.Collection().String() 493 + repo := parsed.Authority().String() 494 + rkey := parsed.RecordKey().String() 495 + 496 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 497 + Collection: collection, 498 + Repo: repo, 499 + Rkey: rkey, 500 + }) 501 + return err 446 502 }
+25
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 7 + "fmt" 6 8 "io" 9 + "net/http" 7 10 8 11 "github.com/bluesky-social/indigo/api/atproto" 9 12 "github.com/bluesky-social/indigo/xrpc" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 15 ) 12 16 ··· 102 106 103 107 return &out, nil 104 108 } 109 + 110 + // produces a more manageable error 111 + func HandleXrpcErr(err error) error { 112 + if err == nil { 113 + return nil 114 + } 115 + 116 + var xrpcerr *indigoxrpc.Error 117 + if ok := errors.As(err, &xrpcerr); !ok { 118 + return fmt.Errorf("Recieved invalid XRPC error response.") 119 + } 120 + 121 + switch xrpcerr.StatusCode { 122 + case http.StatusNotFound: 123 + return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 + case http.StatusUnauthorized: 125 + return fmt.Errorf("Unauthorized XRPC request.") 126 + default: 127 + return fmt.Errorf("Failed to perform operation. Try again later.") 128 + } 129 + }
+19 -19
docs/hacking.md
··· 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, head to `http://localhost:3000/knots` in the browser 59 - and create a knot with hostname `localhost:6000`. This will 60 - generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 - ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 - don't lose it. 58 + To begin, grab your DID from http://localhost:3000/settings. 59 + Then, set `TANGLED_VM_KNOT_OWNER` and 60 + `TANGLED_VM_SPINDLE_OWNER` to your DID. 63 61 64 - You will also need to set the `$TANGLED_VM_SPINDLE_OWNER` 65 - variable to some value. If you don't want to [set up a 66 - spindle](#running-a-spindle), you can use any placeholder 67 - value. 62 + If you don't want to [set up a spindle](#running-a-spindle), 63 + you can use any placeholder value. 68 64 69 65 You can now start a lightweight NixOS VM like so: 70 66 ··· 75 71 ``` 76 72 77 73 This starts a knot on port 6000, a spindle on port 6555 78 - with `ssh` exposed on port 2222. You can push repositories 79 - to this VM with this ssh config block on your main machine: 74 + with `ssh` exposed on port 2222. 75 + 76 + Once the services are running, head to 77 + http://localhost:3000/knots and hit verify (and similarly, 78 + http://localhost:3000/spindles to verify your spindle). It 79 + should verify the ownership of the services instantly if 80 + everything went smoothly. 81 + 82 + You can push repositories to this VM with this ssh config 83 + block on your main machine: 80 84 81 85 ```bash 82 86 Host nixos-shell ··· 95 99 96 100 ## running a spindle 97 101 98 - You will need to find out your DID by entering your login handle into 99 - <https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID. 100 - 101 - The above VM should already be running a spindle on `localhost:6555`. 102 - You can head to the spindle dashboard on `http://localhost:3000/spindles`, 103 - and register a spindle with hostname `localhost:6555`. It should instantly 104 - be verified. You can then configure each repository to use this spindle 105 - and run CI jobs. 102 + The above VM should already be running a spindle on 103 + `localhost:6555`. Head to http://localhost:3000/spindles and 104 + hit verify. You can then configure each repository to use 105 + this spindle and run CI jobs. 106 106 107 107 Of interest when debugging spindles: 108 108
+7 -5
docs/knot-hosting.md
··· 73 73 ``` 74 74 75 75 Create `/home/git/.knot.env` with the following, updating the values as 76 - necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 - [/knots](https://tangled.sh/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_OWNER` should be set to your 77 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 78 78 79 79 ``` 80 80 KNOT_REPO_SCAN_PATH=/home/git 81 81 KNOT_SERVER_HOSTNAME=knot.example.com 82 82 APPVIEW_ENDPOINT=https://tangled.sh 83 - KNOT_SERVER_SECRET=secret 83 + KNOT_SERVER_OWNER=did:plc:foobar 84 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 85 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 86 86 ``` ··· 128 128 Remember to use Let's Encrypt or similar to procure a certificate for your 129 129 knot domain. 130 130 131 - You should now have a running knot server! You can finalize your registration by hitting the 132 - `initialize` button on the [/knots](https://tangled.sh/knots) page. 131 + You should now have a running knot server! You can finalize 132 + your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.sh/knots) page. This simply creates 134 + a record on your PDS to announce the existence of the knot. 133 135 134 136 ### custom paths 135 137
+35
docs/migrations/knot-1.7.0.md
··· 1 + # Upgrading from v1.7.0 2 + 3 + After v1.7.0, knot secrets have been deprecated. You no 4 + longer need a secret from the appview to run a knot. All 5 + authorized commands to knots are managed via [Inter-Service 6 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 + Knots will be read-only until upgraded. 8 + 9 + Upgrading is quite easy, in essence: 10 + 11 + - `KNOT_SERVER_SECRET` is no more, you can remove this 12 + environment variable entirely 13 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 + your DID. You can find your DID in the 15 + [settings](https://tangled.sh/settings) page. 16 + - Restart your knot once you have replaced the environment 17 + variable 18 + - Head to the [knot dashboard](https://tangled.sh/knots) and 19 + hit the "retry" button to verify your knot. This simply 20 + writes a `sh.tangled.knot` record to your PDS. 21 + 22 + ## Nix 23 + 24 + If you use the nix module, simply bump the flake to the 25 + latest revision, and change your config block like so: 26 + 27 + ```diff 28 + services.tangled-knot = { 29 + enable = true; 30 + server = { 31 + - secretFile = /path/to/secret; 32 + + owner = "did:plc:foo"; 33 + }; 34 + }; 35 + ```
+1 -1
flake.nix
··· 252 252 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 253 cd "$rootDir" 254 254 255 - rm api/tangled/* 255 + rm -f api/tangled/* 256 256 lexgen --build-file lexicon-build-config.json lexicons 257 257 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 258 258 ${pkgs.gotools}/bin/goimports -w api/tangled/*
-336
knotclient/signer.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log" 12 - "net/http" 13 - "net/url" 14 - "time" 15 - 16 - "tangled.sh/tangled.sh/core/types" 17 - ) 18 - 19 - type SignerTransport struct { 20 - Secret string 21 - } 22 - 23 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 - timestamp := time.Now().Format(time.RFC3339) 25 - mac := hmac.New(sha256.New, []byte(s.Secret)) 26 - message := req.Method + req.URL.Path + timestamp 27 - mac.Write([]byte(message)) 28 - signature := hex.EncodeToString(mac.Sum(nil)) 29 - req.Header.Set("X-Signature", signature) 30 - req.Header.Set("X-Timestamp", timestamp) 31 - return http.DefaultTransport.RoundTrip(req) 32 - } 33 - 34 - type SignedClient struct { 35 - Secret string 36 - Url *url.URL 37 - client *http.Client 38 - } 39 - 40 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 - client := &http.Client{ 42 - Timeout: 5 * time.Second, 43 - Transport: SignerTransport{ 44 - Secret: secret, 45 - }, 46 - } 47 - 48 - scheme := "https" 49 - if dev { 50 - scheme = "http" 51 - } 52 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - signedClient := &SignedClient{ 58 - Secret: secret, 59 - client: client, 60 - Url: url, 61 - } 62 - 63 - return signedClient, nil 64 - } 65 - 66 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 - } 69 - 70 - func (s *SignedClient) Init(did string) (*http.Response, error) { 71 - const ( 72 - Method = "POST" 73 - Endpoint = "/init" 74 - ) 75 - 76 - body, _ := json.Marshal(map[string]any{ 77 - "did": did, 78 - }) 79 - 80 - req, err := s.newRequest(Method, Endpoint, body) 81 - if err != nil { 82 - return nil, err 83 - } 84 - 85 - return s.client.Do(req) 86 - } 87 - 88 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 - const ( 90 - Method = "PUT" 91 - Endpoint = "/repo/new" 92 - ) 93 - 94 - body, _ := json.Marshal(map[string]any{ 95 - "did": did, 96 - "name": repoName, 97 - "default_branch": defaultBranch, 98 - }) 99 - 100 - req, err := s.newRequest(Method, Endpoint, body) 101 - if err != nil { 102 - return nil, err 103 - } 104 - 105 - return s.client.Do(req) 106 - } 107 - 108 - func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 - const ( 110 - Method = "GET" 111 - ) 112 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 - 114 - req, err := s.newRequest(Method, endpoint, nil) 115 - if err != nil { 116 - return nil, err 117 - } 118 - 119 - resp, err := s.client.Do(req) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - var result types.RepoLanguageResponse 125 - if resp.StatusCode != http.StatusOK { 126 - log.Println("failed to calculate languages", resp.Status) 127 - return &types.RepoLanguageResponse{}, nil 128 - } 129 - 130 - body, err := io.ReadAll(resp.Body) 131 - if err != nil { 132 - return nil, err 133 - } 134 - 135 - err = json.Unmarshal(body, &result) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return &result, nil 141 - } 142 - 143 - func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 - const ( 145 - Method = "GET" 146 - ) 147 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": ownerDid, 151 - "source": source, 152 - "name": name, 153 - "hiddenref": hiddenRef, 154 - }) 155 - 156 - req, err := s.newRequest(Method, endpoint, body) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - return s.client.Do(req) 162 - } 163 - 164 - func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 - 170 - body, _ := json.Marshal(map[string]any{ 171 - "did": ownerDid, 172 - "source": source, 173 - "name": name, 174 - }) 175 - 176 - req, err := s.newRequest(Method, endpoint, body) 177 - if err != nil { 178 - return nil, err 179 - } 180 - 181 - return s.client.Do(req) 182 - } 183 - 184 - func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 - const ( 186 - Method = "POST" 187 - Endpoint = "/repo/fork" 188 - ) 189 - 190 - body, _ := json.Marshal(map[string]any{ 191 - "did": ownerDid, 192 - "source": source, 193 - "name": name, 194 - }) 195 - 196 - req, err := s.newRequest(Method, Endpoint, body) 197 - if err != nil { 198 - return nil, err 199 - } 200 - 201 - return s.client.Do(req) 202 - } 203 - 204 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 - const ( 206 - Method = "DELETE" 207 - Endpoint = "/repo" 208 - ) 209 - 210 - body, _ := json.Marshal(map[string]any{ 211 - "did": did, 212 - "name": repoName, 213 - }) 214 - 215 - req, err := s.newRequest(Method, Endpoint, body) 216 - if err != nil { 217 - return nil, err 218 - } 219 - 220 - return s.client.Do(req) 221 - } 222 - 223 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 - const ( 225 - Method = "PUT" 226 - Endpoint = "/member/add" 227 - ) 228 - 229 - body, _ := json.Marshal(map[string]any{ 230 - "did": did, 231 - }) 232 - 233 - req, err := s.newRequest(Method, Endpoint, body) 234 - if err != nil { 235 - return nil, err 236 - } 237 - 238 - return s.client.Do(req) 239 - } 240 - 241 - func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 - const ( 243 - Method = "PUT" 244 - ) 245 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 - 247 - body, _ := json.Marshal(map[string]any{ 248 - "branch": branch, 249 - }) 250 - 251 - req, err := s.newRequest(Method, endpoint, body) 252 - if err != nil { 253 - return nil, err 254 - } 255 - 256 - return s.client.Do(req) 257 - } 258 - 259 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 - const ( 261 - Method = "POST" 262 - ) 263 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 - 265 - body, _ := json.Marshal(map[string]any{ 266 - "did": memberDid, 267 - }) 268 - 269 - req, err := s.newRequest(Method, endpoint, body) 270 - if err != nil { 271 - return nil, err 272 - } 273 - 274 - return s.client.Do(req) 275 - } 276 - 277 - func (s *SignedClient) Merge( 278 - patch []byte, 279 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 - ) (*http.Response, error) { 281 - const ( 282 - Method = "POST" 283 - ) 284 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 - 286 - mr := types.MergeRequest{ 287 - Branch: branch, 288 - CommitMessage: commitMessage, 289 - CommitBody: commitBody, 290 - AuthorName: authorName, 291 - AuthorEmail: authorEmail, 292 - Patch: string(patch), 293 - } 294 - 295 - body, _ := json.Marshal(mr) 296 - 297 - req, err := s.newRequest(Method, endpoint, body) 298 - if err != nil { 299 - return nil, err 300 - } 301 - 302 - return s.client.Do(req) 303 - } 304 - 305 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 - const ( 307 - Method = "POST" 308 - ) 309 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 - 311 - body, _ := json.Marshal(map[string]any{ 312 - "patch": string(patch), 313 - "branch": branch, 314 - }) 315 - 316 - req, err := s.newRequest(Method, endpoint, body) 317 - if err != nil { 318 - return nil, err 319 - } 320 - 321 - return s.client.Do(req) 322 - } 323 - 324 - func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 - const ( 326 - Method = "POST" 327 - ) 328 - endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 - 330 - req, err := s.newRequest(Method, endpoint, nil) 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return s.client.Do(req) 336 - }
+35
knotclient/unsigned.go
··· 248 248 249 249 return &formatPatchResponse, nil 250 250 } 251 + 252 + func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 + const ( 254 + Method = "GET" 255 + ) 256 + endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 + 258 + req, err := s.newRequest(Method, endpoint, nil, nil) 259 + if err != nil { 260 + return nil, err 261 + } 262 + 263 + resp, err := s.client.Do(req) 264 + if err != nil { 265 + return nil, err 266 + } 267 + 268 + var result types.RepoLanguageResponse 269 + if resp.StatusCode != http.StatusOK { 270 + log.Println("failed to calculate languages", resp.Status) 271 + return &types.RepoLanguageResponse{}, nil 272 + } 273 + 274 + body, err := io.ReadAll(resp.Body) 275 + if err != nil { 276 + return nil, err 277 + } 278 + 279 + err = json.Unmarshal(body, &result) 280 + if err != nil { 281 + return nil, err 282 + } 283 + 284 + return &result, nil 285 + }
+236 -177
knotserver/handler.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 + "compress/gzip" 4 5 "context" 6 + "crypto/sha256" 7 + "encoding/json" 8 + "errors" 5 9 "fmt" 6 - "log/slog" 10 + "log" 7 11 "net/http" 8 - "runtime/debug" 12 + "net/url" 13 + "path/filepath" 14 + "strconv" 15 + "strings" 16 + "sync" 17 + "time" 9 18 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 + "github.com/gliderlabs/ssh" 10 21 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 22 + "github.com/go-git/go-git/v5/plumbing" 23 + "github.com/go-git/go-git/v5/plumbing/object" 14 24 "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/knotserver/git" 19 26 "tangled.sh/tangled.sh/core/types" 20 27 ) 21 28 22 - type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 - resolver *idresolver.Resolver 29 + func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 30 31 } 31 32 32 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 - r := chi.NewRouter() 33 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 + w.Header().Set("Content-Type", "application/json") 34 35 35 - h := Handle{ 36 - c: c, 37 - db: db, 38 - e: e, 39 - l: l, 40 - jc: jc, 41 - n: n, 42 - resolver: idresolver.DefaultResolver(), 36 + capabilities := map[string]any{ 37 + "pull_requests": map[string]any{ 38 + "format_patch": true, 39 + "patch_submissions": true, 40 + "branch_submissions": true, 41 + "fork_submissions": true, 42 + }, 43 + "xrpc": true, 43 44 } 44 45 45 - err := e.AddKnot(rbac.ThisServer) 46 + jsonData, err := json.Marshal(capabilities) 46 47 if err != nil { 47 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 48 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 + return 48 50 } 49 51 50 - // configure owner 51 - if err = h.configureOwner(); err != nil { 52 - return nil, err 53 - } 54 - h.l.Info("owner set", "did", h.c.Server.Owner) 55 - h.jc.AddDid(h.c.Server.Owner) 52 + w.Write(jsonData) 53 + } 54 + 55 + func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 + l := h.l.With("path", path, "handler", "RepoIndex") 58 + ref := chi.URLParam(r, "ref") 59 + ref, _ = url.PathUnescape(ref) 56 60 57 - // configure known-dids in jetstream consumer 58 - dids, err := h.db.GetAllDids() 61 + gr, err := git.Open(path, ref) 59 62 if err != nil { 60 - return nil, fmt.Errorf("failed to get all dids: %w", err) 61 - } 62 - for _, d := range dids { 63 - jc.AddDid(d) 63 + plain, err2 := git.PlainOpen(path) 64 + if err2 != nil { 65 + l.Error("opening repo", "error", err2.Error()) 66 + notFound(w) 67 + return 68 + } 69 + branches, _ := plain.Branches() 70 + 71 + log.Println(err) 72 + 73 + if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 + resp := types.RepoIndexResponse{ 75 + IsEmpty: true, 76 + Branches: branches, 77 + } 78 + writeJSON(w, resp) 79 + return 80 + } else { 81 + l.Error("opening repo", "error", err.Error()) 82 + notFound(w) 83 + return 84 + } 64 85 } 65 86 66 - err = h.jc.StartJetstream(ctx, h.processMessages) 67 - if err != nil { 68 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 69 - } 87 + var ( 88 + commits []*object.Commit 89 + total int 90 + branches []types.Branch 91 + files []types.NiceTree 92 + tags []object.Tag 93 + ) 70 94 71 - r.Get("/", h.Index) 72 - r.Get("/capabilities", h.Capabilities) 73 - r.Get("/version", h.Version) 74 - r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 - w.Write([]byte(h.c.Server.Owner)) 76 - }) 77 - r.Route("/{did}", func(r chi.Router) { 78 - // Repo routes 79 - r.Route("/{name}", func(r chi.Router) { 80 - r.Route("/collaborator", func(r chi.Router) { 81 - r.Use(h.VerifySignature) 82 - r.Post("/add", h.AddRepoCollaborator) 83 - }) 95 + var wg sync.WaitGroup 96 + errorsCh := make(chan error, 5) 84 97 85 - r.Route("/languages", func(r chi.Router) { 86 - r.With(h.VerifySignature) 87 - r.Get("/", h.RepoLanguages) 88 - r.Get("/{ref}", h.RepoLanguages) 89 - }) 98 + wg.Add(1) 99 + go func() { 100 + defer wg.Done() 101 + cs, err := gr.Commits(0, 60) 102 + if err != nil { 103 + errorsCh <- fmt.Errorf("commits: %w", err) 104 + return 105 + } 106 + commits = cs 107 + }() 90 108 91 - r.Get("/", h.RepoIndex) 92 - r.Get("/info/refs", h.InfoRefs) 93 - r.Post("/git-upload-pack", h.UploadPack) 94 - r.Post("/git-receive-pack", h.ReceivePack) 95 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 109 + wg.Add(1) 110 + go func() { 111 + defer wg.Done() 112 + t, err := gr.TotalCommits() 113 + if err != nil { 114 + errorsCh <- fmt.Errorf("calculating total: %w", err) 115 + return 116 + } 117 + total = t 118 + }() 96 119 97 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 120 + wg.Add(1) 121 + go func() { 122 + defer wg.Done() 123 + bs, err := gr.Branches() 124 + if err != nil { 125 + errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 + return 127 + } 128 + branches = bs 129 + }() 98 130 99 - r.Route("/merge", func(r chi.Router) { 100 - r.With(h.VerifySignature) 101 - r.Post("/", h.Merge) 102 - r.Post("/check", h.MergeCheck) 103 - }) 131 + wg.Add(1) 132 + go func() { 133 + defer wg.Done() 134 + ts, err := gr.Tags() 135 + if err != nil { 136 + errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 + return 138 + } 139 + tags = ts 140 + }() 104 141 105 - r.Route("/tree/{ref}", func(r chi.Router) { 106 - r.Get("/", h.RepoIndex) 107 - r.Get("/*", h.RepoTree) 108 - }) 142 + wg.Add(1) 143 + go func() { 144 + defer wg.Done() 145 + fs, err := gr.FileTree(r.Context(), "") 146 + if err != nil { 147 + errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 + return 149 + } 150 + files = fs 151 + }() 109 152 110 - r.Route("/blob/{ref}", func(r chi.Router) { 111 - r.Get("/*", h.Blob) 112 - }) 153 + wg.Wait() 154 + close(errorsCh) 113 155 114 - r.Route("/raw/{ref}", func(r chi.Router) { 115 - r.Get("/*", h.BlobRaw) 116 - }) 156 + // show any errors 157 + for err := range errorsCh { 158 + l.Error("loading repo", "error", err.Error()) 159 + writeError(w, err.Error(), http.StatusInternalServerError) 160 + return 161 + } 117 162 118 - r.Get("/log/{ref}", h.Log) 119 - r.Get("/archive/{file}", h.Archive) 120 - r.Get("/commit/{ref}", h.Diff) 121 - r.Get("/tags", h.Tags) 122 - r.Route("/branches", func(r chi.Router) { 123 - r.Get("/", h.Branches) 124 - r.Get("/{branch}", h.Branch) 125 - r.Route("/default", func(r chi.Router) { 126 - r.Get("/", h.DefaultBranch) 127 - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 128 - }) 129 - }) 130 - }) 131 - }) 163 + rtags := []*types.TagReference{} 164 + for _, tag := range tags { 165 + var target *object.Tag 166 + if tag.Target != plumbing.ZeroHash { 167 + target = &tag 168 + } 169 + tr := types.TagReference{ 170 + Tag: target, 171 + } 132 172 133 - // xrpc apis 134 - r.Mount("/xrpc", h.XrpcRouter()) 173 + tr.Reference = types.Reference{ 174 + Name: tag.Name, 175 + Hash: tag.Hash.String(), 176 + } 135 177 136 - // Create a new repository. 137 - r.Route("/repo", func(r chi.Router) { 138 - r.Use(h.VerifySignature) 139 - r.Delete("/", h.RemoveRepo) 140 - r.Route("/fork", func(r chi.Router) { 141 - r.Post("/", h.RepoFork) 142 - r.Post("/sync/*", h.RepoForkSync) 143 - r.Get("/sync/*", h.RepoForkAheadBehind) 144 - }) 145 - }) 178 + if tag.Message != "" { 179 + tr.Message = tag.Message 180 + } 146 181 147 - r.Route("/member", func(r chi.Router) { 148 - r.Use(h.VerifySignature) 149 - r.Put("/add", h.AddMember) 150 - }) 182 + rtags = append(rtags, &tr) 183 + } 151 184 152 - // Socket that streams git oplogs 153 - r.Get("/events", h.Events) 185 + var readmeContent string 186 + var readmeFile string 187 + for _, readme := range h.c.Repo.Readme { 188 + content, _ := gr.FileContent(readme) 189 + if len(content) > 0 { 190 + readmeContent = string(content) 191 + readmeFile = readme 192 + } 193 + } 154 194 155 - // Health check. Used for two-way verification with appview. 156 - r.With(h.VerifySignature).Get("/health", h.Health) 195 + if ref == "" { 196 + mainBranch, err := gr.FindMainBranch() 197 + if err != nil { 198 + writeError(w, err.Error(), http.StatusInternalServerError) 199 + l.Error("finding main branch", "error", err.Error()) 200 + return 201 + } 202 + ref = mainBranch 203 + } 157 204 158 - // All public keys on the knot. 159 - r.Get("/keys", h.Keys) 205 + resp := types.RepoIndexResponse{ 206 + IsEmpty: false, 207 + Ref: ref, 208 + Commits: commits, 209 + Description: getDescription(path), 210 + Readme: readmeContent, 211 + ReadmeFileName: readmeFile, 212 + Files: files, 213 + Branches: branches, 214 + Tags: rtags, 215 + TotalCommits: total, 216 + } 160 217 161 - return r, nil 218 + writeJSON(w, resp) 162 219 } 163 220 164 - func (h *Handle) XrpcRouter() http.Handler { 165 - logger := tlog.New("knots") 221 + func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 + treePath := chi.URLParam(r, "*") 223 + ref := chi.URLParam(r, "ref") 224 + ref, _ = url.PathUnescape(ref) 166 225 167 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 226 + l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 168 227 169 - xrpc := &xrpc.Xrpc{ 170 - Config: h.c, 171 - Db: h.db, 172 - Ingester: h.jc, 173 - Enforcer: h.e, 174 - Logger: logger, 175 - Notifier: h.n, 176 - Resolver: h.resolver, 177 - ServiceAuth: serviceAuth, 228 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 + gr, err := git.Open(path, ref) 230 + if err != nil { 231 + notFound(w) 232 + return 178 233 } 179 - return xrpc.Router() 180 - } 181 234 182 - // version is set during build time. 183 - var version string 184 - 185 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 186 - if version == "" { 187 - info, ok := debug.ReadBuildInfo() 188 - if !ok { 189 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 190 - return 191 - } 192 - 193 - var modVer string 194 - for _, mod := range info.Deps { 195 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 196 - version = mod.Version 197 - break 198 - } 199 - } 235 + files, err := gr.FileTree(r.Context(), treePath) 236 + if err != nil { 237 + writeError(w, err.Error(), http.StatusInternalServerError) 238 + l.Error("file tree", "error", err.Error()) 239 + return 240 + } 200 241 201 - if modVer == "" { 202 - version = "unknown" 203 - } 242 + resp := types.RepoTreeResponse{ 243 + Ref: ref, 244 + Parent: treePath, 245 + Description: getDescription(path), 246 + DotDot: filepath.Dir(treePath), 247 + Files: files, 204 248 } 205 249 206 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 207 - fmt.Fprintf(w, "knotserver/%s", version) 250 + writeJSON(w, resp) 208 251 } 209 252 210 - func (h *Handle) configureOwner() error { 211 - cfgOwner := h.c.Server.Owner 253 + func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 + treePath := chi.URLParam(r, "*") 255 + ref := chi.URLParam(r, "ref") 256 + ref, _ = url.PathUnescape(ref) 212 257 213 - rbacDomain := "thisserver" 258 + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 214 259 215 - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 260 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 + gr, err := git.Open(path, ref) 216 262 if err != nil { 217 - return err 263 + notFound(w) 264 + return 218 265 } 219 266 220 - switch len(existing) { 221 - case 0: 222 - // no owner configured, continue 223 - case 1: 224 - // find existing owner 225 - existingOwner := existing[0] 267 + contents, err := gr.RawContent(treePath) 268 + if err != nil { 269 + writeError(w, err.Error(), http.StatusBadRequest) 270 + l.Error("file content", "error", err.Error()) 271 + return 272 + } 273 + 274 + mimeType := http.DetectContentType(contents) 275 + 276 + // exception for svg 277 + if filepath.Ext(treePath) == ".svg" { 278 + mimeType = "image/svg+xml" 279 + } 280 + 281 + contentHash := sha256.Sum256(contents) 282 + eTag := fmt.Sprintf("\"%x\"", contentHash) 226 283 227 - // no ownership change, this is okay 228 - if existingOwner == h.c.Server.Owner { 229 - break 284 + // allow image, video, and text/plain files to be served directly 285 + switch { 286 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 + w.WriteHeader(http.StatusNotModified) 289 + return 230 290 } 291 + w.Header().Set("ETag", eTag) 231 292 232 - // remove existing owner 233 - err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 234 - if err != nil { 235 - return nil 236 - } 293 + case strings.HasPrefix(mimeType, "text/plain"): 294 + w.Header().Set("Cache-Control", "public, no-cache") 295 + 237 296 default: 238 297 l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 239 298 writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
-10
knotserver/http_util.go
··· 20 20 func notFound(w http.ResponseWriter) { 21 21 writeError(w, "not found", http.StatusNotFound) 22 22 } 23 - 24 - func writeMsg(w http.ResponseWriter, msg string) { 25 - writeJSON(w, map[string]string{"msg": msg}) 26 - } 27 - 28 - func writeConflict(w http.ResponseWriter, data interface{}) { 29 - w.Header().Set("Content-Type", "application/json") 30 - w.WriteHeader(http.StatusConflict) 31 - json.NewEncoder(w).Encode(data) 32 - }
+20 -35
knotserver/ingester.go
··· 8 8 "net/http" 9 9 "net/url" 10 10 "path/filepath" 11 - "slices" 12 11 "strings" 13 12 14 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 74 73 } 75 74 l.Info("added member from firehose", "member", record.Subject) 76 75 77 - if err := h.db.AddDid(did); err != nil { 76 + if err := h.db.AddDid(record.Subject); err != nil { 78 77 l.Error("failed to add did", "error", err) 79 78 return fmt.Errorf("failed to add did: %w", err) 80 79 } 81 - h.jc.AddDid(did) 80 + h.jc.AddDid(record.Subject) 82 81 83 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 84 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 85 84 } 86 85 ··· 103 102 l = l.With("target_branch", record.TargetBranch) 104 103 105 104 if record.Source == nil { 106 - reason := "not a branch-based pull request" 107 - l.Info("ignoring pull record", "reason", reason) 108 - return fmt.Errorf("ignoring pull record: %s", reason) 105 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 109 106 } 110 107 111 108 if record.Source.Repo != nil { 112 - reason := "fork based pull" 113 - l.Info("ignoring pull record", "reason", reason) 114 - return fmt.Errorf("ignoring pull record: %s", reason) 115 - } 116 - 117 - allDids, err := h.db.GetAllDids() 118 - if err != nil { 119 - return err 120 - } 121 - 122 - // presently: we only process PRs from collaborators for pipelines 123 - if !slices.Contains(allDids, did) { 124 - reason := "not a known did" 125 - l.Info("rejecting pull record", "reason", reason) 126 - return fmt.Errorf("rejected pull record: %s, %s", reason, did) 109 + return fmt.Errorf("ignoring pull record: fork based pull") 127 110 } 128 111 129 112 repoAt, err := syntax.ParseATURI(record.TargetRepo) 130 113 if err != nil { 131 - return err 114 + return fmt.Errorf("failed to parse ATURI: %w", err) 132 115 } 133 116 134 117 // resolve this aturi to extract the repo record ··· 144 127 145 128 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 146 129 if err != nil { 147 - return err 130 + return fmt.Errorf("failed to resolver repo: %w", err) 148 131 } 149 132 150 133 repo := resp.Value.Val.(*tangled.Repo) 151 134 152 135 if repo.Knot != h.c.Server.Hostname { 153 - reason := "not this knot" 154 - l.Info("rejecting pull record", "reason", reason) 155 - return fmt.Errorf("rejected pull record: %s", reason) 136 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 156 137 } 157 138 158 139 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 159 140 if err != nil { 160 - return err 141 + return fmt.Errorf("failed to construct relative repo path: %w", err) 161 142 } 162 143 163 144 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 164 145 if err != nil { 165 - return err 146 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 166 147 } 167 148 168 149 gr, err := git.Open(repoPath, record.Source.Branch) 169 150 if err != nil { 170 - return err 151 + return fmt.Errorf("failed to open git repository: %w", err) 171 152 } 172 153 173 154 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 174 155 if err != nil { 175 - return err 156 + return fmt.Errorf("failed to open workflow directory: %w", err) 176 157 } 177 158 178 159 var pipeline workflow.RawPipeline ··· 215 196 cp := compiler.Compile(compiler.Parse(pipeline)) 216 197 eventJson, err := json.Marshal(cp) 217 198 if err != nil { 218 - return err 199 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 219 200 } 220 201 221 202 // do not run empty pipelines ··· 274 255 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 275 256 276 257 // check perms for this user 277 - if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil { 278 - return fmt.Errorf("insufficient permissions: %w", err) 258 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 259 + if err != nil { 260 + return fmt.Errorf("failed to check permissions: %w", err) 261 + } 262 + if !ok { 263 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 279 264 } 280 265 281 266 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 317 302 return fmt.Errorf("error reading response body: %w", err) 318 303 } 319 304 320 - for _, key := range strings.Split(string(plaintext), "\n") { 305 + for key := range strings.SplitSeq(string(plaintext), "\n") { 321 306 if key == "" { 322 307 continue 323 308 }
-2
knotserver/internal.go
··· 47 47 } 48 48 49 49 w.WriteHeader(http.StatusNoContent) 50 - return 51 50 } 52 51 53 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 63 62 data = append(data, j) 64 63 } 65 64 writeJSON(w, data) 66 - return 67 65 } 68 66 69 67 type PushOptions struct {
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 - "net/http" 8 - "time" 9 - ) 10 - 11 - func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 - if h.c.Server.Dev { 13 - return next 14 - } 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - signature := r.Header.Get("X-Signature") 17 - if signature == "" || !h.verifyHMAC(signature, r) { 18 - writeError(w, "signature verification failed", http.StatusForbidden) 19 - return 20 - } 21 - next.ServeHTTP(w, r) 22 - }) 23 - } 24 - 25 - func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 - secret := h.c.Server.Secret 27 - timestamp := r.Header.Get("X-Timestamp") 28 - if timestamp == "" { 29 - return false 30 - } 31 - 32 - // Verify that the timestamp is not older than a minute 33 - reqTime, err := time.Parse(time.RFC3339, timestamp) 34 - if err != nil { 35 - return false 36 - } 37 - if time.Since(reqTime) > time.Minute { 38 - return false 39 - } 40 - 41 - message := r.Method + r.URL.Path + timestamp 42 - 43 - mac := hmac.New(sha256.New, []byte(secret)) 44 - mac.Write([]byte(message)) 45 - expectedMAC := mac.Sum(nil) 46 - 47 - signatureBytes, err := hex.DecodeString(signature) 48 - if err != nil { 49 - return false 50 - } 51 - 52 - return hmac.Equal(signatureBytes, expectedMAC) 53 - }
+138 -1176
knotserver/routes.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "compress/gzip" 5 4 "context" 6 - "crypto/sha256" 7 - "encoding/json" 8 - "errors" 9 5 "fmt" 10 - "log" 6 + "log/slog" 11 7 "net/http" 12 - "net/url" 13 - "os" 14 - "path/filepath" 15 - "strconv" 16 - "strings" 17 - "sync" 18 - "time" 8 + "runtime/debug" 19 9 20 - securejoin "github.com/cyphar/filepath-securejoin" 21 - "github.com/gliderlabs/ssh" 22 10 "github.com/go-chi/chi/v5" 23 - "github.com/go-git/go-git/v5/plumbing" 24 - "github.com/go-git/go-git/v5/plumbing/object" 25 - "tangled.sh/tangled.sh/core/hook" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 26 14 "tangled.sh/tangled.sh/core/knotserver/db" 27 - "tangled.sh/tangled.sh/core/knotserver/git" 28 - "tangled.sh/tangled.sh/core/patchutil" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 + "tangled.sh/tangled.sh/core/notifier" 29 18 "tangled.sh/tangled.sh/core/rbac" 30 - "tangled.sh/tangled.sh/core/types" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 31 20 ) 32 21 33 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 34 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 22 + type Handle struct { 23 + c *config.Config 24 + db *db.DB 25 + jc *jetstream.JetstreamClient 26 + e *rbac.Enforcer 27 + l *slog.Logger 28 + n *notifier.Notifier 29 + resolver *idresolver.Resolver 35 30 } 36 31 37 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 38 - w.Header().Set("Content-Type", "application/json") 32 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 + r := chi.NewRouter() 39 34 40 - capabilities := map[string]any{ 41 - "pull_requests": map[string]any{ 42 - "format_patch": true, 43 - "patch_submissions": true, 44 - "branch_submissions": true, 45 - "fork_submissions": true, 46 - }, 35 + h := Handle{ 36 + c: c, 37 + db: db, 38 + e: e, 39 + l: l, 40 + jc: jc, 41 + n: n, 42 + resolver: idresolver.DefaultResolver(), 47 43 } 48 44 49 - jsonData, err := json.Marshal(capabilities) 45 + err := e.AddKnot(rbac.ThisServer) 50 46 if err != nil { 51 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 52 - return 47 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 48 } 54 49 55 - w.Write(jsonData) 56 - } 57 - 58 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 59 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 60 - l := h.l.With("path", path, "handler", "RepoIndex") 61 - ref := chi.URLParam(r, "ref") 62 - ref, _ = url.PathUnescape(ref) 63 - 64 - gr, err := git.Open(path, ref) 65 - if err != nil { 66 - plain, err2 := git.PlainOpen(path) 67 - if err2 != nil { 68 - l.Error("opening repo", "error", err2.Error()) 69 - notFound(w) 70 - return 71 - } 72 - branches, _ := plain.Branches() 73 - 74 - log.Println(err) 75 - 76 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 77 - resp := types.RepoIndexResponse{ 78 - IsEmpty: true, 79 - Branches: branches, 80 - } 81 - writeJSON(w, resp) 82 - return 83 - } else { 84 - l.Error("opening repo", "error", err.Error()) 85 - notFound(w) 86 - return 87 - } 50 + // configure owner 51 + if err = h.configureOwner(); err != nil { 52 + return nil, err 88 53 } 89 - 90 - var ( 91 - commits []*object.Commit 92 - total int 93 - branches []types.Branch 94 - files []types.NiceTree 95 - tags []object.Tag 96 - ) 97 - 98 - var wg sync.WaitGroup 99 - errorsCh := make(chan error, 5) 100 - 101 - wg.Add(1) 102 - go func() { 103 - defer wg.Done() 104 - cs, err := gr.Commits(0, 60) 105 - if err != nil { 106 - errorsCh <- fmt.Errorf("commits: %w", err) 107 - return 108 - } 109 - commits = cs 110 - }() 111 - 112 - wg.Add(1) 113 - go func() { 114 - defer wg.Done() 115 - t, err := gr.TotalCommits() 116 - if err != nil { 117 - errorsCh <- fmt.Errorf("calculating total: %w", err) 118 - return 119 - } 120 - total = t 121 - }() 54 + h.l.Info("owner set", "did", h.c.Server.Owner) 55 + h.jc.AddDid(h.c.Server.Owner) 122 56 123 - wg.Add(1) 124 - go func() { 125 - defer wg.Done() 126 - bs, err := gr.Branches() 127 - if err != nil { 128 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 129 - return 130 - } 131 - branches = bs 132 - }() 133 - 134 - wg.Add(1) 135 - go func() { 136 - defer wg.Done() 137 - ts, err := gr.Tags() 138 - if err != nil { 139 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 140 - return 141 - } 142 - tags = ts 143 - }() 144 - 145 - wg.Add(1) 146 - go func() { 147 - defer wg.Done() 148 - fs, err := gr.FileTree(r.Context(), "") 149 - if err != nil { 150 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 151 - return 152 - } 153 - files = fs 154 - }() 155 - 156 - wg.Wait() 157 - close(errorsCh) 158 - 159 - // show any errors 160 - for err := range errorsCh { 161 - l.Error("loading repo", "error", err.Error()) 162 - writeError(w, err.Error(), http.StatusInternalServerError) 163 - return 164 - } 165 - 166 - rtags := []*types.TagReference{} 167 - for _, tag := range tags { 168 - var target *object.Tag 169 - if tag.Target != plumbing.ZeroHash { 170 - target = &tag 171 - } 172 - tr := types.TagReference{ 173 - Tag: target, 174 - } 175 - 176 - tr.Reference = types.Reference{ 177 - Name: tag.Name, 178 - Hash: tag.Hash.String(), 179 - } 180 - 181 - if tag.Message != "" { 182 - tr.Message = tag.Message 183 - } 184 - 185 - rtags = append(rtags, &tr) 186 - } 187 - 188 - var readmeContent string 189 - var readmeFile string 190 - for _, readme := range h.c.Repo.Readme { 191 - content, _ := gr.FileContent(readme) 192 - if len(content) > 0 { 193 - readmeContent = string(content) 194 - readmeFile = readme 195 - } 196 - } 197 - 198 - if ref == "" { 199 - mainBranch, err := gr.FindMainBranch() 200 - if err != nil { 201 - writeError(w, err.Error(), http.StatusInternalServerError) 202 - l.Error("finding main branch", "error", err.Error()) 203 - return 204 - } 205 - ref = mainBranch 206 - } 207 - 208 - resp := types.RepoIndexResponse{ 209 - IsEmpty: false, 210 - Ref: ref, 211 - Commits: commits, 212 - Description: getDescription(path), 213 - Readme: readmeContent, 214 - ReadmeFileName: readmeFile, 215 - Files: files, 216 - Branches: branches, 217 - Tags: rtags, 218 - TotalCommits: total, 219 - } 220 - 221 - writeJSON(w, resp) 222 - return 223 - } 224 - 225 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 226 - treePath := chi.URLParam(r, "*") 227 - ref := chi.URLParam(r, "ref") 228 - ref, _ = url.PathUnescape(ref) 229 - 230 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 231 - 232 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 233 - gr, err := git.Open(path, ref) 57 + // configure known-dids in jetstream consumer 58 + dids, err := h.db.GetAllDids() 234 59 if err != nil { 235 - notFound(w) 236 - return 60 + return nil, fmt.Errorf("failed to get all dids: %w", err) 237 61 } 238 - 239 - files, err := gr.FileTree(r.Context(), treePath) 240 - if err != nil { 241 - writeError(w, err.Error(), http.StatusInternalServerError) 242 - l.Error("file tree", "error", err.Error()) 243 - return 62 + for _, d := range dids { 63 + jc.AddDid(d) 244 64 } 245 65 246 - resp := types.RepoTreeResponse{ 247 - Ref: ref, 248 - Parent: treePath, 249 - Description: getDescription(path), 250 - DotDot: filepath.Dir(treePath), 251 - Files: files, 252 - } 253 - 254 - writeJSON(w, resp) 255 - return 256 - } 257 - 258 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 259 - treePath := chi.URLParam(r, "*") 260 - ref := chi.URLParam(r, "ref") 261 - ref, _ = url.PathUnescape(ref) 262 - 263 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 264 - 265 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 266 - gr, err := git.Open(path, ref) 66 + err = h.jc.StartJetstream(ctx, h.processMessages) 267 67 if err != nil { 268 - notFound(w) 269 - return 68 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 270 69 } 271 70 272 - contents, err := gr.RawContent(treePath) 273 - if err != nil { 274 - writeError(w, err.Error(), http.StatusBadRequest) 275 - l.Error("file content", "error", err.Error()) 276 - return 277 - } 71 + r.Get("/", h.Index) 72 + r.Get("/capabilities", h.Capabilities) 73 + r.Get("/version", h.Version) 74 + r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 + w.Write([]byte(h.c.Server.Owner)) 76 + }) 77 + r.Route("/{did}", func(r chi.Router) { 78 + // Repo routes 79 + r.Route("/{name}", func(r chi.Router) { 278 80 279 - mimeType := http.DetectContentType(contents) 81 + r.Route("/languages", func(r chi.Router) { 82 + r.Get("/", h.RepoLanguages) 83 + r.Get("/{ref}", h.RepoLanguages) 84 + }) 280 85 281 - // exception for svg 282 - if filepath.Ext(treePath) == ".svg" { 283 - mimeType = "image/svg+xml" 284 - } 86 + r.Get("/", h.RepoIndex) 87 + r.Get("/info/refs", h.InfoRefs) 88 + r.Post("/git-upload-pack", h.UploadPack) 89 + r.Post("/git-receive-pack", h.ReceivePack) 90 + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 285 91 286 - contentHash := sha256.Sum256(contents) 287 - eTag := fmt.Sprintf("\"%x\"", contentHash) 92 + r.Route("/tree/{ref}", func(r chi.Router) { 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/*", h.RepoTree) 95 + }) 288 96 289 - // allow image, video, and text/plain files to be served directly 290 - switch { 291 - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 292 - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 293 - w.WriteHeader(http.StatusNotModified) 294 - return 295 - } 296 - w.Header().Set("ETag", eTag) 97 + r.Route("/blob/{ref}", func(r chi.Router) { 98 + r.Get("/*", h.Blob) 99 + }) 297 100 298 - case strings.HasPrefix(mimeType, "text/plain"): 299 - w.Header().Set("Cache-Control", "public, no-cache") 300 - 301 - default: 302 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 303 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 304 - return 305 - } 306 - 307 - w.Header().Set("Content-Type", mimeType) 308 - w.Write(contents) 309 - } 310 - 311 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 312 - treePath := chi.URLParam(r, "*") 313 - ref := chi.URLParam(r, "ref") 314 - ref, _ = url.PathUnescape(ref) 315 - 316 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 317 - 318 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 319 - gr, err := git.Open(path, ref) 320 - if err != nil { 321 - notFound(w) 322 - return 323 - } 324 - 325 - var isBinaryFile bool = false 326 - contents, err := gr.FileContent(treePath) 327 - if errors.Is(err, git.ErrBinaryFile) { 328 - isBinaryFile = true 329 - } else if errors.Is(err, object.ErrFileNotFound) { 330 - notFound(w) 331 - return 332 - } else if err != nil { 333 - writeError(w, err.Error(), http.StatusInternalServerError) 334 - return 335 - } 336 - 337 - bytes := []byte(contents) 338 - // safe := string(sanitize(bytes)) 339 - sizeHint := len(bytes) 340 - 341 - resp := types.RepoBlobResponse{ 342 - Ref: ref, 343 - Contents: string(bytes), 344 - Path: treePath, 345 - IsBinary: isBinaryFile, 346 - SizeHint: uint64(sizeHint), 347 - } 348 - 349 - h.showFile(resp, w, l) 350 - } 351 - 352 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 353 - name := chi.URLParam(r, "name") 354 - file := chi.URLParam(r, "file") 355 - 356 - l := h.l.With("handler", "Archive", "name", name, "file", file) 357 - 358 - // TODO: extend this to add more files compression (e.g.: xz) 359 - if !strings.HasSuffix(file, ".tar.gz") { 360 - notFound(w) 361 - return 362 - } 363 - 364 - ref := strings.TrimSuffix(file, ".tar.gz") 365 - 366 - unescapedRef, err := url.PathUnescape(ref) 367 - if err != nil { 368 - notFound(w) 369 - return 370 - } 371 - 372 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 373 - 374 - // This allows the browser to use a proper name for the file when 375 - // downloading 376 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 377 - setContentDisposition(w, filename) 378 - setGZipMIME(w) 379 - 380 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 381 - gr, err := git.Open(path, unescapedRef) 382 - if err != nil { 383 - notFound(w) 384 - return 385 - } 386 - 387 - gw := gzip.NewWriter(w) 388 - defer gw.Close() 389 - 390 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 391 - err = gr.WriteTar(gw, prefix) 392 - if err != nil { 393 - // once we start writing to the body we can't report error anymore 394 - // so we are only left with printing the error. 395 - l.Error("writing tar file", "error", err.Error()) 396 - return 397 - } 398 - 399 - err = gw.Flush() 400 - if err != nil { 401 - // once we start writing to the body we can't report error anymore 402 - // so we are only left with printing the error. 403 - l.Error("flushing?", "error", err.Error()) 404 - return 405 - } 406 - } 407 - 408 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 409 - ref := chi.URLParam(r, "ref") 410 - ref, _ = url.PathUnescape(ref) 411 - 412 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 413 - 414 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 415 - 416 - gr, err := git.Open(path, ref) 417 - if err != nil { 418 - notFound(w) 419 - return 420 - } 421 - 422 - // Get page parameters 423 - page := 1 424 - pageSize := 30 425 - 426 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 427 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 428 - page = p 429 - } 430 - } 431 - 432 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 433 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 434 - pageSize = ps 435 - } 436 - } 437 - 438 - // convert to offset/limit 439 - offset := (page - 1) * pageSize 440 - limit := pageSize 441 - 442 - commits, err := gr.Commits(offset, limit) 443 - if err != nil { 444 - writeError(w, err.Error(), http.StatusInternalServerError) 445 - l.Error("fetching commits", "error", err.Error()) 446 - return 447 - } 448 - 449 - total := len(commits) 450 - 451 - resp := types.RepoLogResponse{ 452 - Commits: commits, 453 - Ref: ref, 454 - Description: getDescription(path), 455 - Log: true, 456 - Total: total, 457 - Page: page, 458 - PerPage: pageSize, 459 - } 460 - 461 - writeJSON(w, resp) 462 - return 463 - } 464 - 465 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 466 - ref := chi.URLParam(r, "ref") 467 - ref, _ = url.PathUnescape(ref) 468 - 469 - l := h.l.With("handler", "Diff", "ref", ref) 470 - 471 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 472 - gr, err := git.Open(path, ref) 473 - if err != nil { 474 - notFound(w) 475 - return 476 - } 477 - 478 - diff, err := gr.Diff() 479 - if err != nil { 480 - writeError(w, err.Error(), http.StatusInternalServerError) 481 - l.Error("getting diff", "error", err.Error()) 482 - return 483 - } 484 - 485 - resp := types.RepoCommitResponse{ 486 - Ref: ref, 487 - Diff: diff, 488 - } 489 - 490 - writeJSON(w, resp) 491 - return 492 - } 493 - 494 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 495 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 496 - l := h.l.With("handler", "Refs") 497 - 498 - gr, err := git.Open(path, "") 499 - if err != nil { 500 - notFound(w) 501 - return 502 - } 503 - 504 - tags, err := gr.Tags() 505 - if err != nil { 506 - // Non-fatal, we *should* have at least one branch to show. 507 - l.Warn("getting tags", "error", err.Error()) 508 - } 509 - 510 - rtags := []*types.TagReference{} 511 - for _, tag := range tags { 512 - var target *object.Tag 513 - if tag.Target != plumbing.ZeroHash { 514 - target = &tag 515 - } 516 - tr := types.TagReference{ 517 - Tag: target, 518 - } 519 - 520 - tr.Reference = types.Reference{ 521 - Name: tag.Name, 522 - Hash: tag.Hash.String(), 523 - } 524 - 525 - if tag.Message != "" { 526 - tr.Message = tag.Message 527 - } 528 - 529 - rtags = append(rtags, &tr) 530 - } 531 - 532 - resp := types.RepoTagsResponse{ 533 - Tags: rtags, 534 - } 535 - 536 - writeJSON(w, resp) 537 - return 538 - } 539 - 540 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 541 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 542 - 543 - gr, err := git.PlainOpen(path) 544 - if err != nil { 545 - notFound(w) 546 - return 547 - } 101 + r.Route("/raw/{ref}", func(r chi.Router) { 102 + r.Get("/*", h.BlobRaw) 103 + }) 548 104 549 - branches, _ := gr.Branches() 550 - 551 - resp := types.RepoBranchesResponse{ 552 - Branches: branches, 553 - } 554 - 555 - writeJSON(w, resp) 556 - return 557 - } 558 - 559 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 560 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 561 - branchName := chi.URLParam(r, "branch") 562 - branchName, _ = url.PathUnescape(branchName) 563 - 564 - l := h.l.With("handler", "Branch") 565 - 566 - gr, err := git.PlainOpen(path) 567 - if err != nil { 568 - notFound(w) 569 - return 570 - } 571 - 572 - ref, err := gr.Branch(branchName) 573 - if err != nil { 574 - l.Error("getting branch", "error", err.Error()) 575 - writeError(w, err.Error(), http.StatusInternalServerError) 576 - return 577 - } 105 + r.Get("/log/{ref}", h.Log) 106 + r.Get("/archive/{file}", h.Archive) 107 + r.Get("/commit/{ref}", h.Diff) 108 + r.Get("/tags", h.Tags) 109 + r.Route("/branches", func(r chi.Router) { 110 + r.Get("/", h.Branches) 111 + r.Get("/{branch}", h.Branch) 112 + r.Get("/default", h.DefaultBranch) 113 + }) 114 + }) 115 + }) 578 116 579 - commit, err := gr.Commit(ref.Hash()) 580 - if err != nil { 581 - l.Error("getting commit object", "error", err.Error()) 582 - writeError(w, err.Error(), http.StatusInternalServerError) 583 - return 584 - } 117 + // xrpc apis 118 + r.Mount("/xrpc", h.XrpcRouter()) 585 119 586 - defaultBranch, err := gr.FindMainBranch() 587 - isDefault := false 588 - if err != nil { 589 - l.Error("getting default branch", "error", err.Error()) 590 - // do not quit though 591 - } else if defaultBranch == branchName { 592 - isDefault = true 593 - } 120 + // Socket that streams git oplogs 121 + r.Get("/events", h.Events) 594 122 595 - resp := types.RepoBranchResponse{ 596 - Branch: types.Branch{ 597 - Reference: types.Reference{ 598 - Name: ref.Name().Short(), 599 - Hash: ref.Hash().String(), 600 - }, 601 - Commit: commit, 602 - IsDefault: isDefault, 603 - }, 604 - } 123 + // All public keys on the knot. 124 + r.Get("/keys", h.Keys) 605 125 606 - writeJSON(w, resp) 607 - return 126 + return r, nil 608 127 } 609 128 610 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 611 - l := h.l.With("handler", "Keys") 612 - 613 - switch r.Method { 614 - case http.MethodGet: 615 - keys, err := h.db.GetAllPublicKeys() 616 - if err != nil { 617 - writeError(w, err.Error(), http.StatusInternalServerError) 618 - l.Error("getting public keys", "error", err.Error()) 619 - return 620 - } 621 - 622 - data := make([]map[string]any, 0) 623 - for _, key := range keys { 624 - j := key.JSON() 625 - data = append(data, j) 626 - } 627 - writeJSON(w, data) 628 - return 629 - 630 - case http.MethodPut: 631 - pk := db.PublicKey{} 632 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 633 - writeError(w, "invalid request body", http.StatusBadRequest) 634 - return 635 - } 129 + func (h *Handle) XrpcRouter() http.Handler { 130 + logger := tlog.New("knots") 636 131 637 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 638 - if err != nil { 639 - writeError(w, "invalid pubkey", http.StatusBadRequest) 640 - } 132 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 641 133 642 - if err := h.db.AddPublicKey(pk); err != nil { 643 - writeError(w, err.Error(), http.StatusInternalServerError) 644 - l.Error("adding public key", "error", err.Error()) 645 - return 646 - } 647 - 648 - w.WriteHeader(http.StatusNoContent) 649 - return 134 + xrpc := &xrpc.Xrpc{ 135 + Config: h.c, 136 + Db: h.db, 137 + Ingester: h.jc, 138 + Enforcer: h.e, 139 + Logger: logger, 140 + Notifier: h.n, 141 + Resolver: h.resolver, 142 + ServiceAuth: serviceAuth, 650 143 } 144 + return xrpc.Router() 651 145 } 652 146 653 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 654 - l := h.l.With("handler", "RepoForkAheadBehind") 147 + // version is set during build time. 148 + var version string 655 149 656 - data := struct { 657 - Did string `json:"did"` 658 - Source string `json:"source"` 659 - Name string `json:"name,omitempty"` 660 - HiddenRef string `json:"hiddenref"` 661 - }{} 662 - 663 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 664 - writeError(w, "invalid request body", http.StatusBadRequest) 665 - return 666 - } 667 - 668 - did := data.Did 669 - source := data.Source 670 - 671 - if did == "" || source == "" { 672 - l.Error("invalid request body, empty did or name") 673 - w.WriteHeader(http.StatusBadRequest) 674 - return 675 - } 676 - 677 - var name string 678 - if data.Name != "" { 679 - name = data.Name 680 - } else { 681 - name = filepath.Base(source) 682 - } 683 - 684 - branch := chi.URLParam(r, "branch") 685 - branch, _ = url.PathUnescape(branch) 686 - 687 - relativeRepoPath := filepath.Join(did, name) 688 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 689 - 690 - gr, err := git.PlainOpen(repoPath) 691 - if err != nil { 692 - log.Println(err) 693 - notFound(w) 694 - return 695 - } 696 - 697 - forkCommit, err := gr.ResolveRevision(branch) 698 - if err != nil { 699 - l.Error("error resolving ref revision", "msg", err.Error()) 700 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 701 - return 702 - } 703 - 704 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 705 - if err != nil { 706 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 707 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 708 - return 709 - } 710 - 711 - status := types.UpToDate 712 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 713 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 714 - if err != nil { 715 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 150 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 + if version == "" { 152 + info, ok := debug.ReadBuildInfo() 153 + if !ok { 154 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 716 155 return 717 156 } 718 157 719 - if isAncestor { 720 - status = types.FastForwardable 721 - } else { 722 - status = types.Conflict 158 + var modVer string 159 + for _, mod := range info.Deps { 160 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 + version = mod.Version 162 + break 163 + } 723 164 } 724 - } 725 165 726 - w.Header().Set("Content-Type", "application/json") 727 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 728 - } 729 - 730 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 731 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 732 - ref := chi.URLParam(r, "ref") 733 - ref, _ = url.PathUnescape(ref) 734 - 735 - l := h.l.With("handler", "RepoLanguages") 736 - 737 - gr, err := git.Open(repoPath, ref) 738 - if err != nil { 739 - l.Error("opening repo", "error", err.Error()) 740 - notFound(w) 741 - return 742 - } 743 - 744 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 745 - defer cancel() 746 - 747 - sizes, err := gr.AnalyzeLanguages(ctx) 748 - if err != nil { 749 - l.Error("failed to analyze languages", "error", err.Error()) 750 - writeError(w, err.Error(), http.StatusNoContent) 751 - return 752 - } 753 - 754 - resp := types.RepoLanguageResponse{Languages: sizes} 755 - 756 - writeJSON(w, resp) 757 - } 758 - 759 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 760 - l := h.l.With("handler", "RepoForkSync") 761 - 762 - data := struct { 763 - Did string `json:"did"` 764 - Source string `json:"source"` 765 - Name string `json:"name,omitempty"` 766 - }{} 767 - 768 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 769 - writeError(w, "invalid request body", http.StatusBadRequest) 770 - return 771 - } 772 - 773 - did := data.Did 774 - source := data.Source 775 - 776 - if did == "" || source == "" { 777 - l.Error("invalid request body, empty did or name") 778 - w.WriteHeader(http.StatusBadRequest) 779 - return 780 - } 781 - 782 - var name string 783 - if data.Name != "" { 784 - name = data.Name 785 - } else { 786 - name = filepath.Base(source) 787 - } 788 - 789 - branch := chi.URLParam(r, "*") 790 - branch, _ = url.PathUnescape(branch) 791 - 792 - relativeRepoPath := filepath.Join(did, name) 793 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 794 - 795 - gr, err := git.Open(repoPath, branch) 796 - if err != nil { 797 - log.Println(err) 798 - notFound(w) 799 - return 800 - } 801 - 802 - err = gr.Sync() 803 - if err != nil { 804 - l.Error("error syncing repo fork", "error", err.Error()) 805 - writeError(w, err.Error(), http.StatusInternalServerError) 806 - return 807 - } 808 - 809 - w.WriteHeader(http.StatusNoContent) 810 - } 811 - 812 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 813 - l := h.l.With("handler", "RepoFork") 814 - 815 - data := struct { 816 - Did string `json:"did"` 817 - Source string `json:"source"` 818 - Name string `json:"name,omitempty"` 819 - }{} 820 - 821 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 822 - writeError(w, "invalid request body", http.StatusBadRequest) 823 - return 824 - } 825 - 826 - did := data.Did 827 - source := data.Source 828 - 829 - if did == "" || source == "" { 830 - l.Error("invalid request body, empty did or name") 831 - w.WriteHeader(http.StatusBadRequest) 832 - return 833 - } 834 - 835 - var name string 836 - if data.Name != "" { 837 - name = data.Name 838 - } else { 839 - name = filepath.Base(source) 840 - } 841 - 842 - relativeRepoPath := filepath.Join(did, name) 843 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 844 - 845 - err := git.Fork(repoPath, source) 846 - if err != nil { 847 - l.Error("forking repo", "error", err.Error()) 848 - writeError(w, err.Error(), http.StatusInternalServerError) 849 - return 850 - } 851 - 852 - // add perms for this user to access the repo 853 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 854 - if err != nil { 855 - l.Error("adding repo permissions", "error", err.Error()) 856 - writeError(w, err.Error(), http.StatusInternalServerError) 857 - return 858 - } 859 - 860 - hook.SetupRepo( 861 - hook.Config( 862 - hook.WithScanPath(h.c.Repo.ScanPath), 863 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 864 - ), 865 - repoPath, 866 - ) 867 - 868 - w.WriteHeader(http.StatusNoContent) 869 - } 870 - 871 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 872 - l := h.l.With("handler", "RemoveRepo") 873 - 874 - data := struct { 875 - Did string `json:"did"` 876 - Name string `json:"name"` 877 - }{} 878 - 879 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 880 - writeError(w, "invalid request body", http.StatusBadRequest) 881 - return 882 - } 883 - 884 - did := data.Did 885 - name := data.Name 886 - 887 - if did == "" || name == "" { 888 - l.Error("invalid request body, empty did or name") 889 - w.WriteHeader(http.StatusBadRequest) 890 - return 891 - } 892 - 893 - relativeRepoPath := filepath.Join(did, name) 894 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 895 - err := os.RemoveAll(repoPath) 896 - if err != nil { 897 - l.Error("removing repo", "error", err.Error()) 898 - writeError(w, err.Error(), http.StatusInternalServerError) 899 - return 900 - } 901 - 902 - w.WriteHeader(http.StatusNoContent) 903 - 904 - } 905 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 906 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 907 - 908 - data := types.MergeRequest{} 909 - 910 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 911 - writeError(w, err.Error(), http.StatusBadRequest) 912 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 913 - return 914 - } 915 - 916 - mo := &git.MergeOptions{ 917 - AuthorName: data.AuthorName, 918 - AuthorEmail: data.AuthorEmail, 919 - CommitBody: data.CommitBody, 920 - CommitMessage: data.CommitMessage, 921 - } 922 - 923 - patch := data.Patch 924 - branch := data.Branch 925 - gr, err := git.Open(path, branch) 926 - if err != nil { 927 - notFound(w) 928 - return 929 - } 930 - 931 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 932 - 933 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 934 - var mergeErr *git.ErrMerge 935 - if errors.As(err, &mergeErr) { 936 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 937 - for i, conflict := range mergeErr.Conflicts { 938 - conflicts[i] = types.ConflictInfo{ 939 - Filename: conflict.Filename, 940 - Reason: conflict.Reason, 941 - } 942 - } 943 - response := types.MergeCheckResponse{ 944 - IsConflicted: true, 945 - Conflicts: conflicts, 946 - Message: mergeErr.Message, 947 - } 948 - writeConflict(w, response) 949 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 950 - } else { 951 - writeError(w, err.Error(), http.StatusBadRequest) 952 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 166 + if modVer == "" { 167 + version = "unknown" 953 168 } 954 - return 955 169 } 956 170 957 - w.WriteHeader(http.StatusOK) 171 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 + fmt.Fprintf(w, "knotserver/%s", version) 958 173 } 959 174 960 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 961 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 962 - 963 - var data struct { 964 - Patch string `json:"patch"` 965 - Branch string `json:"branch"` 966 - } 175 + func (h *Handle) configureOwner() error { 176 + cfgOwner := h.c.Server.Owner 967 177 968 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 969 - writeError(w, err.Error(), http.StatusBadRequest) 970 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 971 - return 972 - } 178 + rbacDomain := "thisserver" 973 179 974 - patch := data.Patch 975 - branch := data.Branch 976 - gr, err := git.Open(path, branch) 180 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 977 181 if err != nil { 978 - notFound(w) 979 - return 182 + return err 980 183 } 981 184 982 - err = gr.MergeCheck([]byte(patch), branch) 983 - if err == nil { 984 - response := types.MergeCheckResponse{ 985 - IsConflicted: false, 986 - } 987 - writeJSON(w, response) 988 - return 989 - } 185 + switch len(existing) { 186 + case 0: 187 + // no owner configured, continue 188 + case 1: 189 + // find existing owner 190 + existingOwner := existing[0] 990 191 991 - var mergeErr *git.ErrMerge 992 - if errors.As(err, &mergeErr) { 993 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 994 - for i, conflict := range mergeErr.Conflicts { 995 - conflicts[i] = types.ConflictInfo{ 996 - Filename: conflict.Filename, 997 - Reason: conflict.Reason, 998 - } 999 - } 1000 - response := types.MergeCheckResponse{ 1001 - IsConflicted: true, 1002 - Conflicts: conflicts, 1003 - Message: mergeErr.Message, 192 + // no ownership change, this is okay 193 + if existingOwner == h.c.Server.Owner { 194 + break 1004 195 } 1005 - writeConflict(w, response) 1006 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1007 - return 1008 - } 1009 - writeError(w, err.Error(), http.StatusInternalServerError) 1010 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1011 - } 1012 196 1013 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1014 - rev1 := chi.URLParam(r, "rev1") 1015 - rev1, _ = url.PathUnescape(rev1) 1016 - 1017 - rev2 := chi.URLParam(r, "rev2") 1018 - rev2, _ = url.PathUnescape(rev2) 1019 - 1020 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1021 - 1022 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1023 - gr, err := git.PlainOpen(path) 1024 - if err != nil { 1025 - notFound(w) 1026 - return 1027 - } 1028 - 1029 - commit1, err := gr.ResolveRevision(rev1) 1030 - if err != nil { 1031 - l.Error("error resolving revision 1", "msg", err.Error()) 1032 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1033 - return 1034 - } 1035 - 1036 - commit2, err := gr.ResolveRevision(rev2) 1037 - if err != nil { 1038 - l.Error("error resolving revision 2", "msg", err.Error()) 1039 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1040 - return 1041 - } 1042 - 1043 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1044 - if err != nil { 1045 - l.Error("error comparing revisions", "msg", err.Error()) 1046 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1047 - return 1048 - } 1049 - 1050 - writeJSON(w, types.RepoFormatPatchResponse{ 1051 - Rev1: commit1.Hash.String(), 1052 - Rev2: commit2.Hash.String(), 1053 - FormatPatch: formatPatch, 1054 - Patch: rawPatch, 1055 - }) 1056 - return 1057 - } 1058 - 1059 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1060 - l := h.l.With("handler", "NewHiddenRef") 1061 - 1062 - forkRef := chi.URLParam(r, "forkRef") 1063 - forkRef, _ = url.PathUnescape(forkRef) 1064 - 1065 - remoteRef := chi.URLParam(r, "remoteRef") 1066 - remoteRef, _ = url.PathUnescape(remoteRef) 1067 - 1068 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1069 - gr, err := git.PlainOpen(path) 1070 - if err != nil { 1071 - notFound(w) 1072 - return 1073 - } 1074 - 1075 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1076 - if err != nil { 1077 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1078 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1079 - return 1080 - } 1081 - 1082 - w.WriteHeader(http.StatusNoContent) 1083 - return 1084 - } 1085 - 1086 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1087 - l := h.l.With("handler", "AddMember") 1088 - 1089 - data := struct { 1090 - Did string `json:"did"` 1091 - }{} 1092 - 1093 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1094 - writeError(w, "invalid request body", http.StatusBadRequest) 1095 - return 1096 - } 1097 - 1098 - did := data.Did 1099 - 1100 - if err := h.db.AddDid(did); err != nil { 1101 - l.Error("adding did", "error", err.Error()) 1102 - writeError(w, err.Error(), http.StatusInternalServerError) 1103 - return 1104 - } 1105 - h.jc.AddDid(did) 1106 - 1107 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1108 - l.Error("adding member", "error", err.Error()) 1109 - writeError(w, err.Error(), http.StatusInternalServerError) 1110 - return 1111 - } 1112 - 1113 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1114 - l.Error("fetching and adding keys", "error", err.Error()) 1115 - writeError(w, err.Error(), http.StatusInternalServerError) 1116 - return 1117 - } 1118 - 1119 - w.WriteHeader(http.StatusNoContent) 1120 - } 1121 - 1122 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1123 - l := h.l.With("handler", "AddRepoCollaborator") 1124 - 1125 - data := struct { 1126 - Did string `json:"did"` 1127 - }{} 1128 - 1129 - ownerDid := chi.URLParam(r, "did") 1130 - repo := chi.URLParam(r, "name") 1131 - 1132 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1133 - writeError(w, "invalid request body", http.StatusBadRequest) 1134 - return 1135 - } 1136 - 1137 - if err := h.db.AddDid(data.Did); err != nil { 1138 - l.Error("adding did", "error", err.Error()) 1139 - writeError(w, err.Error(), http.StatusInternalServerError) 1140 - return 1141 - } 1142 - h.jc.AddDid(data.Did) 1143 - 1144 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1145 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1146 - l.Error("adding repo collaborator", "error", err.Error()) 1147 - writeError(w, err.Error(), http.StatusInternalServerError) 1148 - return 1149 - } 1150 - 1151 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1152 - l.Error("fetching and adding keys", "error", err.Error()) 1153 - writeError(w, err.Error(), http.StatusInternalServerError) 1154 - return 1155 - } 1156 - 1157 - w.WriteHeader(http.StatusNoContent) 1158 - } 1159 - 1160 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1161 - l := h.l.With("handler", "DefaultBranch") 1162 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1163 - 1164 - gr, err := git.Open(path, "") 1165 - if err != nil { 1166 - notFound(w) 1167 - return 1168 - } 1169 - 1170 - branch, err := gr.FindMainBranch() 1171 - if err != nil { 1172 - writeError(w, err.Error(), http.StatusInternalServerError) 1173 - l.Error("getting default branch", "error", err.Error()) 1174 - return 1175 - } 1176 - 1177 - writeJSON(w, types.RepoDefaultBranchResponse{ 1178 - Branch: branch, 1179 - }) 1180 - } 1181 - 1182 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1183 - l := h.l.With("handler", "SetDefaultBranch") 1184 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1185 - 1186 - data := struct { 1187 - Branch string `json:"branch"` 1188 - }{} 1189 - 1190 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1191 - writeError(w, err.Error(), http.StatusBadRequest) 1192 - return 1193 - } 1194 - 1195 - gr, err := git.PlainOpen(path) 1196 - if err != nil { 1197 - notFound(w) 1198 - return 1199 - } 1200 - 1201 - err = gr.SetDefaultBranch(data.Branch) 1202 - if err != nil { 1203 - writeError(w, err.Error(), http.StatusInternalServerError) 1204 - l.Error("setting default branch", "error", err.Error()) 1205 - return 1206 - } 1207 - 1208 - w.WriteHeader(http.StatusNoContent) 1209 - } 1210 - 1211 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1212 - w.Write([]byte("ok")) 1213 - } 1214 - 1215 - func validateRepoName(name string) error { 1216 - // check for path traversal attempts 1217 - if name == "." || name == ".." || 1218 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1219 - return fmt.Errorf("Repository name contains invalid path characters") 1220 - } 1221 - 1222 - // check for sequences that could be used for traversal when normalized 1223 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1224 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1225 - return fmt.Errorf("Repository name contains invalid path sequence") 1226 - } 1227 - 1228 - // then continue with character validation 1229 - for _, char := range name { 1230 - if !((char >= 'a' && char <= 'z') || 1231 - (char >= 'A' && char <= 'Z') || 1232 - (char >= '0' && char <= '9') || 1233 - char == '-' || char == '_' || char == '.') { 1234 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 197 + // remove existing owner 198 + err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 199 + if err != nil { 200 + return nil 1235 201 } 1236 - } 1237 - 1238 - // additional check to prevent multiple sequential dots 1239 - if strings.Contains(name, "..") { 1240 - return fmt.Errorf("Repository name cannot contain sequential dots") 202 + default: 203 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 1241 204 } 1242 205 1243 - // if all checks pass 1244 - return nil 206 + return h.e.AddKnotOwner(rbacDomain, cfgOwner) 1245 207 }
+93 -79
knotserver/xrpc/delete_repo.go
··· 1 1 package xrpc 2 2 3 - // import ( 4 - // "encoding/json" 5 - // "fmt" 6 - // "net/http" 7 - // "os" 8 - // "path/filepath" 9 - // 10 - // "github.com/bluesky-social/indigo/atproto/syntax" 11 - // securejoin "github.com/cyphar/filepath-securejoin" 12 - // "tangled.sh/tangled.sh/core/api/tangled" 13 - // "tangled.sh/tangled.sh/core/rbac" 14 - // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 - // ) 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 16 9 17 - // func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 18 - // l := x.Logger.With("handler", "DeleteRepo") 19 - // fail := func(e xrpcerr.XrpcError) { 20 - // l.Error("failed", "kind", e.Tag, "error", e.Message) 21 - // writeError(w, e, http.StatusBadRequest) 22 - // } 23 - // 24 - // actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 - // if !ok { 26 - // fail(xrpcerr.MissingActorDidError) 27 - // return 28 - // } 29 - // 30 - // isMember, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer) 31 - // if err != nil { 32 - // fail(xrpcerr.GenericError(err)) 33 - // return 34 - // } 35 - // if !isMember { 36 - // fail(xrpcerr.AccessControlError(actorDid.String())) 37 - // return 38 - // } 39 - // 40 - // var data tangled.RepoDelete_Input 41 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 42 - // fail(xrpcerr.GenericError(err)) 43 - // return 44 - // } 45 - // 46 - // did := data.Did 47 - // name := data.Name 48 - // 49 - // if did == "" || name == "" { 50 - // fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 51 - // return 52 - // } 53 - // 54 - // relativeRepoPath := filepath.Join(did, name) 55 - // if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 56 - // l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 57 - // writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 58 - // return 59 - // } 60 - // 61 - // repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 62 - // if err != nil { 63 - // fail(xrpcerr.GenericError(err)) 64 - // return 65 - // } 66 - // 67 - // err = os.RemoveAll(repoPath) 68 - // if err != nil { 69 - // l.Error("deleting repo", "error", err.Error()) 70 - // writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 71 - // return 72 - // } 73 - // 74 - // err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 75 - // if err != nil { 76 - // l.Error("failed to delete repo from enforcer", "error", err.Error()) 77 - // writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 78 - // return 79 - // } 80 - // 81 - // w.WriteHeader(http.StatusOK) 82 - // } 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/rbac" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "DeleteRepo") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDelete_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + rkey := data.Rkey 41 + 42 + if did == "" || name == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 + return 45 + } 46 + 47 + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 + if err != nil || ident.Handle.IsInvalidHandle() { 49 + fail(xrpcerr.GenericError(err)) 50 + return 51 + } 52 + 53 + xrpcc := xrpc.Client{ 54 + Host: ident.PDSEndpoint(), 55 + } 56 + 57 + // ensure that the record does not exists 58 + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 + if err == nil { 60 + fail(xrpcerr.RecordExistsError(rkey)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + if !isDeleteAllowed { 71 + fail(xrpcerr.AccessControlError(actorDid.String())) 72 + return 73 + } 74 + 75 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = os.RemoveAll(repoPath) 82 + if err != nil { 83 + l.Error("deleting repo", "error", err.Error()) 84 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 + if err != nil { 90 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + w.WriteHeader(http.StatusOK) 96 + }
-56
knotserver/xrpc/router.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "log/slog" 6 - "net/http" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/idresolver" 10 - "tangled.sh/tangled.sh/core/jetstream" 11 - "tangled.sh/tangled.sh/core/knotserver/config" 12 - "tangled.sh/tangled.sh/core/knotserver/db" 13 - "tangled.sh/tangled.sh/core/notifier" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 17 - 18 - "github.com/go-chi/chi/v5" 19 - ) 20 - 21 - type Xrpc struct { 22 - Config *config.Config 23 - Db *db.DB 24 - Ingester *jetstream.JetstreamClient 25 - Enforcer *rbac.Enforcer 26 - Logger *slog.Logger 27 - Notifier *notifier.Notifier 28 - Resolver *idresolver.Resolver 29 - ServiceAuth *serviceauth.ServiceAuth 30 - } 31 - 32 - func (x *Xrpc) Router() http.Handler { 33 - r := chi.NewRouter() 34 - r.Group(func(r chi.Router) { 35 - r.Use(x.ServiceAuth.VerifyServiceAuth) 36 - 37 - r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 38 - r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 39 - r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 40 - r.Post("/"+tangled.RepoForkNSID, x.ForkRepo) 41 - r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 - r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 43 - 44 - r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 45 - 46 - r.Post("/"+tangled.RepoMergeNSID, x.Merge) 47 - r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 48 - }) 49 - return r 50 - } 51 - 52 - func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 53 - w.Header().Set("Content-Type", "application/json") 54 - w.WriteHeader(status) 55 - json.NewEncoder(w).Encode(e) 56 - }
+60
knotserver/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/idresolver" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/config" 12 + "tangled.sh/tangled.sh/core/knotserver/db" 13 + "tangled.sh/tangled.sh/core/notifier" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 17 + 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + type Xrpc struct { 22 + Config *config.Config 23 + Db *db.DB 24 + Ingester *jetstream.JetstreamClient 25 + Enforcer *rbac.Enforcer 26 + Logger *slog.Logger 27 + Notifier *notifier.Notifier 28 + Resolver *idresolver.Resolver 29 + ServiceAuth *serviceauth.ServiceAuth 30 + } 31 + 32 + func (x *Xrpc) Router() http.Handler { 33 + r := chi.NewRouter() 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(x.ServiceAuth.VerifyServiceAuth) 37 + 38 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 39 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 40 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 41 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 43 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 44 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 45 + }) 46 + 47 + // merge check is an open endpoint 48 + // 49 + // TODO: should we constrain this more? 50 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 + // - use ETags on clients to keep requests to a minimum 52 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 + return r 54 + } 55 + 56 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 + w.Header().Set("Content-Type", "application/json") 58 + w.WriteHeader(status) 59 + json.NewEncoder(w).Encode(e) 60 + }
+5 -1
lexicons/repo/delete.json
··· 9 9 "encoding": "application/json", 10 10 "schema": { 11 11 "type": "object", 12 - "required": ["did", "name"], 12 + "required": ["did", "name", "rkey"], 13 13 "properties": { 14 14 "did": { 15 15 "type": "string", ··· 19 19 "name": { 20 20 "type": "string", 21 21 "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 22 26 } 23 27 } 24 28 }
+5 -5
nix/modules/knot.nix
··· 93 93 description = "Internal address for inter-service communication"; 94 94 }; 95 95 96 - secretFile = mkOption { 97 - type = lib.types.path; 98 - example = "KNOT_SERVER_SECRET=<hash>"; 99 - description = "File containing secret key provided by appview (required)"; 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (required)"; 100 100 }; 101 101 102 102 dbPath = mkOption { ··· 199 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 202 203 ]; 203 - EnvironmentFile = cfg.server.secretFile; 204 204 ExecStart = "${cfg.package}/bin/knot server"; 205 205 Restart = "always"; 206 206 };
+2 -1
nix/vm.nix
··· 70 70 }; 71 71 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 72 networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 73 74 services.getty.autologinUser = "root"; 74 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 75 76 services.tangled-knot = { 76 77 enable = true; 77 78 motd = "Welcome to the development knot!\n"; 78 79 server = { 79 - secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 80 81 hostname = "localhost:6000"; 81 82 listenAddr = "0.0.0.0:6000"; 82 83 };
+13
rbac/rbac.go
··· 100 100 return err 101 101 } 102 102 103 + func (e *Enforcer) RemoveKnot(knot string) error { 104 + _, err := e.E.DeleteDomains(knot) 105 + return err 106 + } 107 + 103 108 func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) { 104 109 keepFunc := isNotSpindle 105 110 stripFunc := unSpindle ··· 270 275 271 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 272 277 return e.isInviteAllowed(user, intoSpindle(domain)) 278 + } 279 + 280 + func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 + return e.E.Enforce(user, domain, domain, "repo:create") 282 + } 283 + 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 273 286 } 274 287 275 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+7
xrpc/errors/errors.go
··· 86 86 ) 87 87 } 88 88 89 + var RecordExistsError = func(r string) XrpcError { 90 + return NewXrpcError( 91 + WithTag("RecordExists"), 92 + WithError(fmt.Errorf("repo already exists: %s", r)), 93 + ) 94 + } 95 + 89 96 func GenericError(err error) XrpcError { 90 97 return NewXrpcError( 91 98 WithTag("Generic"),