diff --git a/appview/ingester.go b/appview/ingester.go index 35e65458..2037415e 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -14,7 +14,7 @@ import ( "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview/config" "tangled.sh/tangled.sh/core/appview/db" - "tangled.sh/tangled.sh/core/appview/spindleverify" + "tangled.sh/tangled.sh/core/appview/serververify" "tangled.sh/tangled.sh/core/idresolver" "tangled.sh/tangled.sh/core/rbac" ) @@ -475,13 +475,13 @@ func (i *Ingester) ingestSpindle(e *models.Event) error { return err } - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) + err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) if err != nil { l.Error("failed to add spindle to db", "err", err, "instance", instance) return err } - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) if err != nil { return fmt.Errorf("failed to mark verified: %w", err) } diff --git a/appview/knots/knots.go b/appview/knots/knots.go index eebba586..f32151e4 100644 --- a/appview/knots/knots.go +++ b/appview/knots/knots.go @@ -1,14 +1,11 @@ package knots import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" + "errors" "fmt" "log/slog" "net/http" - "strings" + "slices" "time" "github.com/go-chi/chi/v5" @@ -18,9 +15,9 @@ import ( "tangled.sh/tangled.sh/core/appview/middleware" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/serververify" "tangled.sh/tangled.sh/core/eventconsumer" "tangled.sh/tangled.sh/core/idresolver" - "tangled.sh/tangled.sh/core/knotclient" "tangled.sh/tangled.sh/core/rbac" "tangled.sh/tangled.sh/core/tid" @@ -39,36 +36,29 @@ type Knots struct { Knotstream *eventconsumer.Consumer } -func (k *Knots) Router(mw *middleware.Middleware) http.Handler { +func (k *Knots) Router() http.Handler { r := chi.NewRouter() - r.Use(middleware.AuthMiddleware(k.OAuth)) + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) - r.Get("/", k.index) - r.Post("/key", k.generateKey) + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) - r.Route("/{domain}", func(r chi.Router) { - r.Post("/init", k.init) - r.Get("/", k.dashboard) - r.Route("/member", func(r chi.Router) { - r.Use(mw.KnotOwner()) - r.Get("/", k.members) - r.Put("/", k.addMember) - r.Delete("/", k.removeMember) - }) - }) + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) return r } -// get knots registered by this user -func (k *Knots) index(w http.ResponseWriter, r *http.Request) { - l := k.Logger.With("handler", "index") - +func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { user := k.OAuth.GetUser(r) registrations, err := db.RegistrationsByDid(k.Db, user.Did) if err != nil { - l.Error("failed to get registrations by did", "err", err) + k.Logger.Error("failed to fetch knot registrations", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return } k.Pages.Knots(w, pages.KnotsParams{ @@ -77,402 +67,615 @@ func (k *Knots) index(w http.ResponseWriter, r *http.Request) { }) } -// requires auth -func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { - l := k.Logger.With("handler", "generateKey") +func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { + l := k.Logger.With("handler", "dashboard") user := k.OAuth.GetUser(r) - did := user.Did - l = l.With("did", did) + l = l.With("user", user.Did) - // check if domain is valid url, and strip extra bits down to just host - domain := r.FormValue("domain") + domain := chi.URLParam(r, "domain") if domain == "" { - l.Error("empty domain") - http.Error(w, "Invalid form", http.StatusBadRequest) return } l = l.With("domain", domain) - noticeId := "registration-error" - fail := func() { - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") + registrations, err := db.GetRegistrations( + k.Db, + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), + ) + if err != nil { + l.Error("failed to get registrations", "err", err) + http.Error(w, "Not found", http.StatusNotFound) + return } - key, err := db.GenerateRegistrationKey(k.Db, domain, did) + // Find the specific registration for this domain + var registration *db.Registration + for _, reg := range registrations { + if reg.Domain == domain && reg.ByDid == user.Did && reg.Registered != nil { + registration = ® + break + } + } + + if registration == nil { + l.Error("registration not found or not verified") + http.Error(w, "Not found", http.StatusNotFound) + return + } + registration := registrations[0] + + members, err := k.Enforcer.GetUserByRole("server:member", domain) if err != nil { - l.Error("failed to generate registration key", "err", err) - fail() + l.Error("failed to get knot members", "err", err) + http.Error(w, "Not found", http.StatusInternalServerError) return } + slices.Sort(members) - allRegs, err := db.RegistrationsByDid(k.Db, did) + repos, err := db.GetRepos( + k.Db, + 0, + db.FilterEq("knot", domain), + ) if err != nil { - l.Error("failed to generate registration key", "err", err) - fail() + l.Error("failed to get knot repos", "err", err) + http.Error(w, "Not found", http.StatusInternalServerError) return } - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ - Registrations: allRegs, - }) - k.Pages.KnotSecret(w, pages.KnotSecretParams{ - Secret: key, + // organize repos by did + repoMap := make(map[string][]db.Repo) + for _, r := range repos { + repoMap[r.Did] = append(repoMap[r.Did], r) + } + + k.Pages.Knot(w, pages.KnotParams{ + LoggedInUser: user, + Registration: ®istration, + Members: members, + Repos: repoMap, + IsOwner: true, }) } -// create a signed request and check if a node responds to that -func (k *Knots) init(w http.ResponseWriter, r *http.Request) { - l := k.Logger.With("handler", "init") +func (k *Knots) register(w http.ResponseWriter, r *http.Request) { user := k.OAuth.GetUser(r) + l := k.Logger.With("handler", "register") - noticeId := "operation-error" - defaultErr := "Failed to initialize knot. Try again later." + noticeId := "register-error" + defaultErr := "Failed to register knot. Try again later." fail := func() { k.Pages.Notice(w, noticeId, defaultErr) } - domain := chi.URLParam(r, "domain") + domain := r.FormValue("domain") if domain == "" { - http.Error(w, "malformed url", http.StatusBadRequest) + k.Pages.Notice(w, noticeId, "Incomplete form.") return } l = l.With("domain", domain) + l = l.With("user", user.Did) - l.Info("checking domain") + tx, err := k.Db.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + fail() + return + } + defer func() { + tx.Rollback() + k.Enforcer.E.LoadPolicy() + }() - registration, err := db.RegistrationByDomain(k.Db, domain) + err = db.AddKnot(tx, domain, user.Did) if err != nil { - l.Error("failed to get registration for domain", "err", err) + l.Error("failed to insert", "err", err) fail() return } - if registration.ByDid != user.Did { - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) - w.WriteHeader(http.StatusUnauthorized) + + err = k.Enforcer.AddKnot(domain) + if err != nil { + l.Error("failed to create knot", "err", err) + fail() return } - secret, err := db.GetRegistrationKey(k.Db, domain) + // create record on pds + client, err := k.OAuth.AuthorizedClient(r) if err != nil { - l.Error("failed to get registration key for domain", "err", err) + l.Error("failed to authorize client", "err", err) fail() return } - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) + var exCid *string + if ex != nil { + exCid = ex.Cid + } + + // re-announce by registering under same rkey + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.KnotNSID, + Repo: user.Did, + Rkey: domain, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.Knot{ + CreatedAt: time.Now().Format(time.RFC3339), + }, + }, + SwapRecord: exCid, + }) + if err != nil { - l.Error("failed to create knotclient", "err", err) + l.Error("failed to put record", "err", err) fail() return } - resp, err := client.Init(user.Did) + err = tx.Commit() if err != nil { - k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) - l.Error("failed to make init request", "err", err) + l.Error("failed to commit transaction", "err", err) + fail() return } - if resp.StatusCode == http.StatusConflict { - k.Pages.Notice(w, noticeId, "This knot is already registered") - l.Error("knot already registered", "statuscode", resp.StatusCode) + err = k.Enforcer.E.SavePolicy() + if err != nil { + l.Error("failed to update ACL", "err", err) + k.Pages.HxRefresh(w) return } - if resp.StatusCode != http.StatusNoContent { - k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) - l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) + // begin verification + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) + if err != nil { + l.Error("verification failed", "err", err) + k.Pages.HxRefresh(w) return } - // verify response mac - signature := resp.Header.Get("X-Signature") - signatureBytes, err := hex.DecodeString(signature) + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) if err != nil { + l.Error("failed to mark verified", "err", err) + k.Pages.HxRefresh(w) return } - expectedMac := hmac.New(sha256.New, []byte(secret)) - expectedMac.Write([]byte("ok")) + // add this knot to knotstream + go k.Knotstream.AddSource( + r.Context(), + eventconsumer.NewKnotSource(domain), + ) - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") - l.Error("signature mismatch", "bytes", signatureBytes) - return + // ok + k.Pages.HxRefresh(w) +} + +func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { + user := k.OAuth.GetUser(r) + l := k.Logger.With("handler", "delete") + + noticeId := "operation-error" + defaultErr := "Failed to delete knot. Try again later." + fail := func() { + k.Pages.Notice(w, noticeId, defaultErr) } - tx, err := k.Db.BeginTx(r.Context(), nil) - if err != nil { - l.Error("failed to start tx", "err", err) + domain := chi.URLParam(r, "domain") + if domain == "" { + l.Error("empty domain") fail() return } - defer func() { - tx.Rollback() - err = k.Enforcer.E.LoadPolicy() - if err != nil { - l.Error("rollback failed", "err", err) - } - }() - // mark as registered - err = db.Register(tx, domain) + // get record from db first + registrations, err := db.GetRegistrations( + k.Db, + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), + ) if err != nil { - l.Error("failed to register domain", "err", err) + l.Error("failed to get registration", "err", err) fail() return } + if len(registrations) != 1 { + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) + fail() + return + } + registration := registrations[0] - // set permissions for this did as owner - reg, err := db.RegistrationByDomain(tx, domain) + tx, err := k.Db.Begin() if err != nil { - l.Error("failed get registration by domain", "err", err) + l.Error("failed to start txn", "err", err) fail() return } + defer func() { + tx.Rollback() + k.Enforcer.E.LoadPolicy() + }() - // add basic acls for this domain - err = k.Enforcer.AddKnot(domain) + err = db.DeleteKnot( + tx, + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), + ) if err != nil { - l.Error("failed to add knot to enforcer", "err", err) + l.Error("failed to delete registration", "err", err) fail() return } - // add this did as owner of this domain - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) + // delete from enforcer if it was registered + if registration.Registered != nil { + err = k.Enforcer.RemoveKnot(domain) + if err != nil { + l.Error("failed to update ACL", "err", err) + fail() + return + } + } + + client, err := k.OAuth.AuthorizedClient(r) if err != nil { - l.Error("failed to add knot owner to enforcer", "err", err) + l.Error("failed to authorize client", "err", err) fail() return } + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + Collection: tangled.KnotNSID, + Repo: user.Did, + Rkey: domain, + }) + if err != nil { + // non-fatal + l.Error("failed to delete record", "err", err) + } + err = tx.Commit() if err != nil { - l.Error("failed to commit changes", "err", err) + l.Error("failed to delete knot", "err", err) fail() return } err = k.Enforcer.E.SavePolicy() if err != nil { - l.Error("failed to update ACLs", "err", err) - fail() + l.Error("failed to update ACL", "err", err) + k.Pages.HxRefresh(w) return } - // add this knot to knotstream - go k.Knotstream.AddSource( - context.Background(), - eventconsumer.NewKnotSource(domain), - ) + shouldRedirect := r.Header.Get("shouldRedirect") + if shouldRedirect == "true" { + k.Pages.HxRedirect(w, "/knots") + return + } - k.Pages.KnotListing(w, pages.KnotListingParams{ - Registration: *reg, - }) + w.Write([]byte{}) } -func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { - l := k.Logger.With("handler", "dashboard") +func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { + user := k.OAuth.GetUser(r) + l := k.Logger.With("handler", "retry") + + noticeId := "operation-error" + defaultErr := "Failed to verify knot. Try again later." fail := func() { - w.WriteHeader(http.StatusInternalServerError) + k.Pages.Notice(w, noticeId, defaultErr) } domain := chi.URLParam(r, "domain") if domain == "" { - http.Error(w, "malformed url", http.StatusBadRequest) + l.Error("empty domain") + fail() return } l = l.With("domain", domain) + l = l.With("user", user.Did) - user := k.OAuth.GetUser(r) - l = l.With("did", user.Did) - - // dashboard is only available to owners - ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) + // get record from db first + registrations, err := db.GetRegistrations( + k.Db, + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), + ) if err != nil { - l.Error("failed to query enforcer", "err", err) + l.Error("failed to get registration", "err", err) fail() + return } - if !ok { - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) + if len(registrations) != 1 { + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) + fail() return } + registration := registrations[0] - reg, err := db.RegistrationByDomain(k.Db, domain) + // begin verification + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) if err != nil { - l.Error("failed to get registration by domain", "err", err) + l.Error("verification failed", "err", err) + + if errors.Is(err, serververify.FetchError) { + k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") + return + } + + if e, ok := err.(*serververify.OwnerMismatch); ok { + k.Pages.Notice(w, noticeId, e.Error()) + return + } + fail() return } - var members []string - if reg.Registered != nil { - members, err = k.Enforcer.GetUserByRole("server:member", domain) + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) + if err != nil { + l.Error("failed to mark verified", "err", err) + k.Pages.Notice(w, noticeId, err.Error()) + return + } + + // if this knot was previously read-only, then emit a record too + // + // this is part of migrating from the old knot system to the new one + if registration.ReadOnly { + // re-announce by registering under same rkey + client, err := k.OAuth.AuthorizedClient(r) if err != nil { - l.Error("failed to get members list", "err", err) + l.Error("failed to authorize client", "err", err) fail() return } + + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) + var exCid *string + if ex != nil { + exCid = ex.Cid + } + + // ignore the error here + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.KnotNSID, + Repo: user.Did, + Rkey: domain, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.Knot{ + CreatedAt: time.Now().Format(time.RFC3339), + }, + }, + SwapRecord: exCid, + }) + if err != nil { + l.Error("non-fatal: failed to reannouce knot", "err", err) + } } - repos, err := db.GetRepos( + // add this knot to knotstream + go k.Knotstream.AddSource( + r.Context(), + eventconsumer.NewKnotSource(domain), + ) + + shouldRefresh := r.Header.Get("shouldRefresh") + if shouldRefresh == "true" { + k.Pages.HxRefresh(w) + return + } + + // Get updated registration to show + registrations, err = db.GetRegistrations( k.Db, - 0, - db.FilterEq("knot", domain), - db.FilterIn("did", members), + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), ) if err != nil { - l.Error("failed to get repos list", "err", err) + l.Error("failed to get registration", "err", err) fail() return } - // convert to map - repoByMember := make(map[string][]db.Repo) - for _, r := range repos { - repoByMember[r.Did] = append(repoByMember[r.Did], r) - } - - k.Pages.Knot(w, pages.KnotParams{ - LoggedInUser: user, - Registration: reg, - Members: members, - Repos: repoByMember, - IsOwner: true, - }) -} - -// list members of domain, requires auth and requires owner status -func (k *Knots) members(w http.ResponseWriter, r *http.Request) { - l := k.Logger.With("handler", "members") - - domain := chi.URLParam(r, "domain") - if domain == "" { - http.Error(w, "malformed url", http.StatusBadRequest) + if len(registrations) != 1 { + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) + fail() return } - l = l.With("domain", domain) + updatedRegistration := registrations[0] - // list all members for this domain - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) - if err != nil { - w.Write([]byte("failed to fetch member list")) - return - } + log.Println(updatedRegistration) - w.Write([]byte(strings.Join(memberDids, "\n"))) + w.Header().Set("HX-Reswap", "outerHTML") + k.Pages.KnotListing(w, pages.KnotListingParams{ + Registration: &updatedRegistration, + }) } -// add member to domain, requires auth and requires invite access func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { - l := k.Logger.With("handler", "members") + user := k.OAuth.GetUser(r) + l := k.Logger.With("handler", "addMember") domain := chi.URLParam(r, "domain") if domain == "" { - http.Error(w, "malformed url", http.StatusBadRequest) + l.Error("empty domain") + http.Error(w, "Not found", http.StatusNotFound) return } l = l.With("domain", domain) + l = l.With("user", user.Did) - reg, err := db.RegistrationByDomain(k.Db, domain) + registrations, err := db.GetRegistrations( + k.Db, + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), + db.FilterIsNot("registered", "null"), + ) if err != nil { - l.Error("failed to get registration by domain", "err", err) - http.Error(w, "malformed url", http.StatusBadRequest) + l.Error("failed to retrieve domain registration", "err", err) + http.Error(w, "Not found", http.StatusNotFound) return } - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) - l = l.With("notice-id", noticeId) + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) defaultErr := "Failed to add member. Try again later." fail := func() { k.Pages.Notice(w, noticeId, defaultErr) } - subjectIdentifier := r.FormValue("subject") - if subjectIdentifier == "" { - http.Error(w, "malformed form", http.StatusBadRequest) + member := r.FormValue("member") + if member == "" { + l.Error("empty member") + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") return } - l = l.With("subjectIdentifier", subjectIdentifier) + l = l.With("member", member) - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) if err != nil { - l.Error("failed to resolve identity", "err", err) + l.Error("failed to resolve member identity to handle", "err", err) + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") + return + } + if memberId.Handle.IsInvalidHandle() { + l.Error("failed to resolve member identity to handle") k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") return } - l = l.With("subjectDid", subjectIdentity.DID) - - l.Info("adding member to knot") - // announce this relation into the firehose, store into owners' pds + // write to pds client, err := k.OAuth.AuthorizedClient(r) if err != nil { - l.Error("failed to create client", "err", err) + l.Error("failed to authorize client", "err", err) fail() return } - currentUser := k.OAuth.GetUser(r) - createdAt := time.Now().Format(time.RFC3339) - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + rkey := tid.TID() + + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: tangled.KnotMemberNSID, - Repo: currentUser.Did, - Rkey: tid.TID(), + Repo: user.Did, + Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.KnotMember{ - Subject: subjectIdentity.DID.String(), + CreatedAt: time.Now().Format(time.RFC3339), Domain: domain, - CreatedAt: createdAt, - }}, + Subject: memberId.DID.String(), + }, + }, }) - // invalid record if err != nil { - l.Error("failed to write to PDS", "err", err) - fail() + l.Error("failed to add record to PDS", "err", err) + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") return } - l = l.With("at-uri", resp.Uri) - l.Info("wrote record to PDS") - secret, err := db.GetRegistrationKey(k.Db, domain) + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) if err != nil { - l.Error("failed to get registration key", "err", err) + l.Error("failed to add member to ACLs", "err", err) fail() return } - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) + err = k.Enforcer.E.SavePolicy() if err != nil { - l.Error("failed to create client", "err", err) + l.Error("failed to save ACL policy", "err", err) + fail() + return + } + + // success + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) +} + +func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { + user := k.OAuth.GetUser(r) + l := k.Logger.With("handler", "removeMember") + + noticeId := "operation-error" + defaultErr := "Failed to remove member. Try again later." + fail := func() { + k.Pages.Notice(w, noticeId, defaultErr) + } + + domain := chi.URLParam(r, "domain") + if domain == "" { + l.Error("empty domain") fail() return } + l = l.With("domain", domain) + l = l.With("user", user.Did) - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) + registrations, err := db.GetRegistrations( + k.Db, + db.FilterEq("did", user.Did), + db.FilterEq("domain", domain), + db.FilterIsNot("registered", "null"), + ) if err != nil { - l.Error("failed to reach knotserver", "err", err) - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") + l.Error("failed to get registration", "err", err) + return + } + if len(registrations) != 1 { + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) return } - if ksResp.StatusCode != http.StatusNoContent { - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) + member := r.FormValue("member") + if member == "" { + l.Error("empty member") + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") return } + l = l.With("member", member) - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) if err != nil { - l.Error("failed to add member to enforcer", "err", err) + l.Error("failed to resolve member identity to handle", "err", err) + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") + return + } + if memberId.Handle.IsInvalidHandle() { + l.Error("failed to resolve member identity to handle") + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") + return + } + + // remove from enforcer + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) + if err != nil { + l.Error("failed to update ACLs", "err", err) fail() return } - // success - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) -} + client, err := k.OAuth.AuthorizedClient(r) + if err != nil { + l.Error("failed to authorize client", "err", err) + fail() + return + } -func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { + // TODO: We need to track the rkey for knot members to delete the record + // For now, just remove from ACLs + _ = client + + // commit everything + err = k.Enforcer.E.SavePolicy() + if err != nil { + l.Error("failed to save ACLs", "err", err) + fail() + return + } + + // ok + k.Pages.HxRefresh(w) } diff --git a/appview/spindleverify/verify.go b/appview/serververify/verify.go similarity index 57% rename from appview/spindleverify/verify.go rename to appview/serververify/verify.go index 7665ed76..4776f77f 100644 --- a/appview/spindleverify/verify.go +++ b/appview/serververify/verify.go @@ -1,4 +1,4 @@ -package spindleverify +package serververify import ( "context" @@ -17,7 +17,7 @@ var ( FetchError = errors.New("failed to fetch owner") ) -// TODO: move this to "spindleclient" or similar +// fetchOwner fetches the owner DID from a server's /owner endpoint func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { scheme := "https" if dev { @@ -61,9 +61,9 @@ func (e *OwnerMismatch) Error() string { return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) } -func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { - // begin verification - observedOwner, err := fetchOwner(ctx, instance, dev) +// RunVerification verifies that the server at the given domain has the expected owner +func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { + observedOwner, err := fetchOwner(ctx, domain, dev) if err != nil { return fmt.Errorf("%w: %w", FetchError, err) } @@ -78,8 +78,8 @@ func RunVerification(ctx context.Context, instance, expectedOwner string, dev bo return nil } -// mark this spindle as verified in the DB and add this user as its owner -func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { +// MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner +func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { tx, err := d.Begin() if err != nil { return 0, fmt.Errorf("failed to create txn: %w", err) @@ -116,3 +116,49 @@ func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, er return rowId, nil } + +// MarkKnotVerified marks a knot as verified and sets up ownership/permissions +func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { + tx, err := d.BeginTx(context.Background(), nil) + if err != nil { + return fmt.Errorf("failed to start tx: %w", err) + } + defer func() { + tx.Rollback() + e.E.LoadPolicy() + }() + + // mark as registered + err = db.MarkRegistered( + tx, + db.FilterEq("did", owner), + db.FilterEq("domain", domain), + ) + if err != nil { + return fmt.Errorf("failed to register domain: %w", err) + } + + // add basic acls for this domain + err = e.AddKnot(domain) + if err != nil { + return fmt.Errorf("failed to add knot to enforcer: %w", err) + } + + // add this did as owner of this domain + err = e.AddKnotOwner(domain, owner) + if err != nil { + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("failed to commit changes: %w", err) + } + + err = e.E.SavePolicy() + if err != nil { + return fmt.Errorf("failed to update ACLs: %w", err) + } + + return nil +} diff --git a/appview/spindles/spindles.go b/appview/spindles/spindles.go index 6c7a5eb5..7c5db881 100644 --- a/appview/spindles/spindles.go +++ b/appview/spindles/spindles.go @@ -15,7 +15,7 @@ import ( "tangled.sh/tangled.sh/core/appview/middleware" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" - verify "tangled.sh/tangled.sh/core/appview/spindleverify" + "tangled.sh/tangled.sh/core/appview/serververify" "tangled.sh/tangled.sh/core/idresolver" "tangled.sh/tangled.sh/core/rbac" "tangled.sh/tangled.sh/core/tid" @@ -227,14 +227,14 @@ func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { } // begin verification - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) if err != nil { l.Error("verification failed", "err", err) s.Pages.HxRefresh(w) return } - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) if err != nil { l.Error("failed to mark verified", "err", err) s.Pages.HxRefresh(w) @@ -400,16 +400,16 @@ func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { } // begin verification - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) if err != nil { l.Error("verification failed", "err", err) - if errors.Is(err, verify.FetchError) { - s.Pages.Notice(w, noticeId, err.Error()) + if errors.Is(err, serververify.FetchError) { + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") return } - if e, ok := err.(*verify.OwnerMismatch); ok { + if e, ok := err.(*serververify.OwnerMismatch); ok { s.Pages.Notice(w, noticeId, e.Error()) return } @@ -418,7 +418,7 @@ func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { return } - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) if err != nil { l.Error("failed to mark verified", "err", err) s.Pages.Notice(w, noticeId, err.Error()) diff --git a/appview/state/router.go b/appview/state/router.go index dcdf2b6b..a41ee4b4 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -147,7 +147,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/settings", s.SettingsRouter()) r.Mount("/strings", s.StringsRouter(mw)) - r.Mount("/knots", s.KnotsRouter(mw)) + r.Mount("/knots", s.KnotsRouter()) r.Mount("/spindles", s.SpindlesRouter()) r.Mount("/signup", s.SignupRouter()) r.Mount("/", s.OAuthRouter()) @@ -195,7 +195,7 @@ func (s *State) SpindlesRouter() http.Handler { return spindles.Router() } -func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { +func (s *State) KnotsRouter() http.Handler { logger := log.New("knots") knots := &knots.Knots{ @@ -209,7 +209,7 @@ func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { Logger: logger, } - return knots.Router(mw) + return knots.Router() } func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { diff --git a/rbac/rbac.go b/rbac/rbac.go index 2def55db..3a586190 100644 --- a/rbac/rbac.go +++ b/rbac/rbac.go @@ -100,6 +100,11 @@ func (e *Enforcer) RemoveSpindle(spindle string) error { return err } +func (e *Enforcer) RemoveKnot(knot string) error { + _, err := e.E.DeleteDomains(knot) + return err +} + func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) { keepFunc := isNotSpindle stripFunc := unSpindle