forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview: implement new ownership process for knots

This is now identical to how we verify spindle registrations, and gets
rid of the registration key. This code is now deduplicated in the
serververify package (previously spindleverify).

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

anirudh.fi 9db30f0a eb4ce023

verified
Changed files
+542 -363
appview
knots
serververify
spindles
spindleverify
state
+371 -234
appview/knots/knots.go
···
package knots
import (
-
"context"
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
"fmt"
"log/slog"
"net/http"
-
"strings"
"time"
"github.com/go-chi/chi/v5"
···
"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/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"
···
Knotstream *eventconsumer.Consumer
}
-
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
-
r.Use(middleware.AuthMiddleware(k.OAuth))
-
r.Get("/", k.index)
-
r.Post("/key", k.generateKey)
-
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)
-
})
-
})
return r
}
-
// get knots registered by this user
-
func (k *Knots) index(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "index")
-
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.Pages.Knots(w, pages.KnotsParams{
···
})
}
-
// requires auth
-
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "generateKey")
user := k.OAuth.GetUser(r)
-
did := user.Did
-
l = l.With("did", did)
-
// check if domain is valid url, and strip extra bits down to just host
-
domain := r.FormValue("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.")
}
-
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
if err != nil {
-
l.Error("failed to generate registration key", "err", err)
-
fail()
return
}
-
allRegs, err := db.RegistrationsByDid(k.Db, did)
if err != nil {
-
l.Error("failed to generate registration key", "err", err)
-
fail()
return
}
-
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
-
Registrations: allRegs,
-
})
-
k.Pages.KnotSecret(w, pages.KnotSecretParams{
-
Secret: key,
})
}
-
// 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")
user := k.OAuth.GetUser(r)
-
noticeId := "operation-error"
-
defaultErr := "Failed to initialize knot. Try again later."
fail := func() {
k.Pages.Notice(w, noticeId, defaultErr)
}
-
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
return
}
l = l.With("domain", domain)
-
l.Info("checking domain")
-
registration, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
-
l.Error("failed to get registration for domain", "err", err)
fail()
return
}
-
if registration.ByDid != user.Did {
-
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
-
w.WriteHeader(http.StatusUnauthorized)
return
}
-
secret, err := db.GetRegistrationKey(k.Db, domain)
if err != nil {
-
l.Error("failed to get registration key for domain", "err", err)
fail()
return
}
-
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
if err != nil {
-
l.Error("failed to create knotclient", "err", err)
fail()
return
}
-
resp, err := client.Init(user.Did)
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)
return
}
-
if resp.StatusCode == http.StatusConflict {
-
k.Pages.Notice(w, noticeId, "This knot is already registered")
-
l.Error("knot already registered", "statuscode", resp.StatusCode)
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)
return
}
-
// verify response mac
-
signature := resp.Header.Get("X-Signature")
-
signatureBytes, err := hex.DecodeString(signature)
if err != nil {
return
}
-
expectedMac := hmac.New(sha256.New, []byte(secret))
-
expectedMac.Write([]byte("ok"))
-
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
}
-
tx, err := k.Db.BeginTx(r.Context(), nil)
if err != nil {
-
l.Error("failed to start tx", "err", err)
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)
if err != nil {
-
l.Error("failed to register domain", "err", err)
fail()
return
}
-
// set permissions for this did as owner
-
reg, err := db.RegistrationByDomain(tx, domain)
-
if err != nil {
-
l.Error("failed get registration by domain", "err", err)
-
fail()
-
return
}
-
// add basic acls for this domain
-
err = k.Enforcer.AddKnot(domain)
if err != nil {
-
l.Error("failed to add knot to enforcer", "err", err)
fail()
return
}
-
// add this did as owner of this domain
-
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
if err != nil {
-
l.Error("failed to add knot owner to enforcer", "err", err)
-
fail()
-
return
}
err = tx.Commit()
if err != nil {
-
l.Error("failed to commit changes", "err", err)
fail()
return
}
err = k.Enforcer.E.SavePolicy()
if err != nil {
-
l.Error("failed to update ACLs", "err", err)
-
fail()
return
}
-
// add this knot to knotstream
-
go k.Knotstream.AddSource(
-
context.Background(),
-
eventconsumer.NewKnotSource(domain),
-
)
-
k.Pages.KnotListing(w, pages.KnotListingParams{
-
Registration: *reg,
-
})
}
-
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "dashboard")
fail := func() {
-
w.WriteHeader(http.StatusInternalServerError)
}
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
return
}
l = l.With("domain", domain)
-
-
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)
if err != nil {
-
l.Error("failed to query enforcer", "err", err)
fail()
-
}
-
if !ok {
-
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
return
}
-
reg, err := db.RegistrationByDomain(k.Db, domain)
-
if err != nil {
-
l.Error("failed to get registration by domain", "err", err)
-
fail()
return
}
-
var members []string
-
if reg.Registered != nil {
-
members, err = k.Enforcer.GetUserByRole("server:member", domain)
-
if err != nil {
-
l.Error("failed to get members list", "err", err)
-
fail()
return
}
-
}
-
repos, err := db.GetRepos(
-
k.Db,
-
0,
-
db.FilterEq("knot", domain),
-
db.FilterIn("did", members),
-
)
-
if err != nil {
-
l.Error("failed to get repos list", "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)
-
}
-
var didsToResolve []string
-
for _, m := range members {
-
didsToResolve = append(didsToResolve, m)
-
}
-
didsToResolve = append(didsToResolve, reg.ByDid)
-
resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
}
-
k.Pages.Knot(w, pages.KnotParams{
-
LoggedInUser: user,
-
DidHandleMap: didHandleMap,
-
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)
return
}
-
l = l.With("domain", domain)
-
// 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
}
-
w.Write([]byte(strings.Join(memberDids, "\n")))
}
-
// 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")
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
return
}
l = l.With("domain", domain)
-
reg, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
-
l.Error("failed to get registration by domain", "err", err)
-
http.Error(w, "malformed url", http.StatusBadRequest)
return
}
-
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
-
l = l.With("notice-id", noticeId)
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)
return
}
-
l = l.With("subjectIdentifier", subjectIdentifier)
-
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
if err != nil {
-
l.Error("failed to resolve identity", "err", err)
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
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to create 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{
Collection: tangled.KnotMemberNSID,
-
Repo: currentUser.Did,
-
Rkey: tid.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
-
Subject: subjectIdentity.DID.String(),
Domain: domain,
-
CreatedAt: createdAt,
-
}},
})
-
// invalid record
if err != nil {
-
l.Error("failed to write to PDS", "err", err)
fail()
return
}
-
l = l.With("at-uri", resp.Uri)
-
l.Info("wrote record to PDS")
-
secret, err := db.GetRegistrationKey(k.Db, domain)
if err != nil {
-
l.Error("failed to get registration key", "err", err)
fail()
return
}
-
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
if err != nil {
-
l.Error("failed to create client", "err", err)
fail()
return
}
-
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
if err != nil {
-
l.Error("failed to reach knotserver", "err", err)
-
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
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))
return
}
-
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
if err != nil {
-
l.Error("failed to add member to enforcer", "err", err)
fail()
return
}
-
// success
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
-
}
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
}
···
package knots
import (
+
"errors"
"fmt"
"log/slog"
"net/http"
+
"slices"
"time"
"github.com/go-chi/chi/v5"
···
"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/rbac"
"tangled.sh/tangled.sh/core/tid"
···
Knotstream *eventconsumer.Consumer
}
+
func (k *Knots) Router() http.Handler {
r := chi.NewRouter()
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
+
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
}
+
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 {
+
k.Logger.Error("failed to fetch knot registrations", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
}
k.Pages.Knots(w, pages.KnotsParams{
···
})
}
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
+
l := k.Logger.With("handler", "dashboard")
user := k.OAuth.GetUser(r)
+
l = l.With("user", user.Did)
+
domain := chi.URLParam(r, "domain")
if domain == "" {
return
}
l = l.With("domain", domain)
+
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
+
if err != nil {
+
l.Error("failed to get registrations", "err", err)
+
http.Error(w, "Not found", http.StatusNotFound)
+
return
}
+
// 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 = &reg
+
break
+
}
+
}
+
+
if registration == nil {
+
l.Error("registration not found or not verified")
+
http.Error(w, "Not found", http.StatusNotFound)
+
return
+
}
+
+
members, err := k.Enforcer.GetUserByRole("server:member", domain)
if err != nil {
+
l.Error("failed to get knot members", "err", err)
+
http.Error(w, "Not found", http.StatusInternalServerError)
return
}
+
slices.Sort(members)
+
repos, err := db.GetRepos(
+
k.Db,
+
0,
+
db.FilterEq("knot", domain),
+
)
if err != nil {
+
l.Error("failed to get knot repos", "err", err)
+
http.Error(w, "Not found", http.StatusInternalServerError)
return
}
+
identsToResolve := make([]string, len(members))
+
copy(identsToResolve, members)
+
resolvedIds := k.IdResolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
// 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: registration,
+
Members: members,
+
Repos: repoMap,
+
DidHandleMap: didHandleMap,
+
IsOwner: true,
})
}
+
func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "register")
+
noticeId := "register-error"
+
defaultErr := "Failed to register knot. Try again later."
fail := func() {
k.Pages.Notice(w, noticeId, defaultErr)
}
+
domain := r.FormValue("domain")
if domain == "" {
+
k.Pages.Notice(w, noticeId, "Incomplete form.")
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
+
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()
+
}()
+
err = db.AddKnot(tx, domain, user.Did)
if err != nil {
+
l.Error("failed to insert", "err", err)
fail()
return
}
+
+
err = k.Enforcer.AddKnot(domain)
+
if err != nil {
+
l.Error("failed to create knot", "err", err)
+
fail()
return
}
+
// create record on pds
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
+
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
+
}
+
+
// 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 put record", "err", err)
fail()
return
}
+
err = tx.Commit()
if err != nil {
+
l.Error("failed to commit transaction", "err", err)
+
fail()
return
}
+
err = k.Enforcer.E.SavePolicy()
+
if err != nil {
+
l.Error("failed to update ACL", "err", err)
+
k.Pages.HxRefresh(w)
return
}
+
// 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
}
+
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
}
+
// add this knot to knotstream
+
go k.Knotstream.AddSource(
+
r.Context(),
+
eventconsumer.NewKnotSource(domain),
+
)
+
+
// 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)
+
}
+
domain := chi.URLParam(r, "domain")
+
if domain == "" {
+
l.Error("empty domain")
+
fail()
return
}
+
registration, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
+
l.Error("failed to retrieve domain registration", "err", err)
+
fail()
+
return
+
}
+
+
if registration.ByDid != user.Did {
+
l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid)
+
k.Pages.Notice(w, noticeId, "Failed to delete knot, unauthorized deletion attempt.")
+
return
+
}
+
+
tx, err := k.Db.Begin()
+
if err != nil {
+
l.Error("failed to start txn", "err", err)
fail()
return
}
defer func() {
tx.Rollback()
+
k.Enforcer.E.LoadPolicy()
}()
+
err = db.DeleteKnot(
+
tx,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
+
l.Error("failed to delete registration", "err", err)
fail()
return
}
+
// 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 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 delete knot", "err", err)
fail()
return
}
err = k.Enforcer.E.SavePolicy()
if err != nil {
+
l.Error("failed to update ACL", "err", err)
+
k.Pages.HxRefresh(w)
return
}
+
shouldRedirect := r.Header.Get("shouldRedirect")
+
if shouldRedirect == "true" {
+
k.Pages.HxRedirect(w, "/knots")
+
return
+
}
+
w.Write([]byte{})
}
+
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() {
+
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)
+
registration, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
+
l.Error("failed to retrieve domain registration", "err", err)
fail()
return
}
+
if registration.ByDid != user.Did {
+
l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid)
+
k.Pages.Notice(w, noticeId, "Failed to verify knot, unauthorized verification attempt.")
return
}
+
// begin verification
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
+
if err != nil {
+
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
}
+
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
}
+
// 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
+
updatedRegistration, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
+
l.Error("failed get updated registration", "err", err)
+
k.Pages.HxRefresh(w)
return
}
+
w.Header().Set("HX-Reswap", "outerHTML")
+
k.Pages.KnotListing(w, pages.KnotListingParams{
+
Registration: *updatedRegistration,
+
})
}
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "addMember")
domain := chi.URLParam(r, "domain")
if domain == "" {
+
l.Error("empty domain")
+
http.Error(w, "Not found", http.StatusNotFound)
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
+
registration, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
+
l.Error("failed to retrieve domain registration", "err", err)
+
http.Error(w, "Not found", http.StatusNotFound)
return
}
+
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)
}
+
if registration.ByDid != user.Did {
+
l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid)
+
k.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
return
}
+
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("member", member)
+
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
+
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
}
+
// write to pds
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
+
rkey := tid.TID()
+
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
+
Repo: user.Did,
+
Rkey: rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
+
CreatedAt: time.Now().Format(time.RFC3339),
Domain: domain,
+
Subject: memberId.DID.String(),
+
},
+
},
})
if err != nil {
+
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
+
}
+
+
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
+
if err != nil {
+
l.Error("failed to add member to ACLs", "err", err)
fail()
return
}
+
err = k.Enforcer.E.SavePolicy()
if err != nil {
+
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)
+
registration, err := db.RegistrationByDomain(k.Db, domain)
if err != nil {
+
l.Error("failed to retrieve domain registration", "err", err)
fail()
return
}
+
if registration.ByDid != user.Did {
+
l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid)
+
k.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
+
return
+
}
+
+
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)
+
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
+
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
}
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
+
// 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)
}
+160
appview/serververify/verify.go
···
···
+
package serververify
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"io"
+
"net/http"
+
"strings"
+
"time"
+
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/rbac"
+
)
+
+
var (
+
FetchError = errors.New("failed to fetch owner")
+
)
+
+
// 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 {
+
scheme = "http"
+
}
+
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
+
req, err := http.NewRequest("GET", url, nil)
+
if err != nil {
+
return "", err
+
}
+
+
client := &http.Client{
+
Timeout: 1 * time.Second,
+
}
+
+
resp, err := client.Do(req.WithContext(ctx))
+
if err != nil || resp.StatusCode != 200 {
+
return "", fmt.Errorf("failed to fetch /owner")
+
}
+
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
+
if err != nil {
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
+
}
+
+
did := strings.TrimSpace(string(body))
+
if did == "" {
+
return "", fmt.Errorf("empty DID in /owner response")
+
}
+
+
return did, nil
+
}
+
+
type OwnerMismatch struct {
+
expected string
+
observed string
+
}
+
+
func (e *OwnerMismatch) Error() string {
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
+
}
+
+
// 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)
+
}
+
+
if observedOwner != expectedOwner {
+
return &OwnerMismatch{
+
expected: expectedOwner,
+
observed: observedOwner,
+
}
+
}
+
+
return nil
+
}
+
+
// 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)
+
}
+
defer func() {
+
tx.Rollback()
+
e.E.LoadPolicy()
+
}()
+
+
// mark this spindle as verified in the db
+
rowId, err := db.VerifySpindle(
+
tx,
+
db.FilterEq("owner", owner),
+
db.FilterEq("instance", instance),
+
)
+
if err != nil {
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
+
}
+
+
err = e.AddSpindleOwner(instance, owner)
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
+
}
+
+
err = e.E.SavePolicy()
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
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.Register(tx, 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
+
}
+8 -8
appview/spindles/spindles.go
···
"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/idresolver"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
}
// begin verification
-
err = verify.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)
if err != nil {
l.Error("failed to mark verified", "err", err)
s.Pages.HxRefresh(w)
···
}
// begin verification
-
err = verify.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())
return
}
-
if e, ok := err.(*verify.OwnerMismatch); ok {
s.Pages.Notice(w, noticeId, e.Error())
return
}
···
return
}
-
rowId, err := verify.MarkVerified(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())
···
"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/idresolver"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
}
// begin verification
+
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 = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
if err != nil {
l.Error("failed to mark verified", "err", err)
s.Pages.HxRefresh(w)
···
}
// begin verification
+
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, serververify.FetchError) {
+
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
return
}
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
s.Pages.Notice(w, noticeId, e.Error())
return
}
···
return
}
+
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())
-118
appview/spindleverify/verify.go
···
-
package spindleverify
-
-
import (
-
"context"
-
"errors"
-
"fmt"
-
"io"
-
"net/http"
-
"strings"
-
"time"
-
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/rbac"
-
)
-
-
var (
-
FetchError = errors.New("failed to fetch owner")
-
)
-
-
// TODO: move this to "spindleclient" or similar
-
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
-
scheme := "https"
-
if dev {
-
scheme = "http"
-
}
-
-
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
-
req, err := http.NewRequest("GET", url, nil)
-
if err != nil {
-
return "", err
-
}
-
-
client := &http.Client{
-
Timeout: 1 * time.Second,
-
}
-
-
resp, err := client.Do(req.WithContext(ctx))
-
if err != nil || resp.StatusCode != 200 {
-
return "", fmt.Errorf("failed to fetch /owner")
-
}
-
-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
-
if err != nil {
-
return "", fmt.Errorf("failed to read /owner response: %w", err)
-
}
-
-
did := strings.TrimSpace(string(body))
-
if did == "" {
-
return "", fmt.Errorf("empty DID in /owner response")
-
}
-
-
return did, nil
-
}
-
-
type OwnerMismatch struct {
-
expected string
-
observed string
-
}
-
-
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)
-
if err != nil {
-
return fmt.Errorf("%w: %w", FetchError, err)
-
}
-
-
if observedOwner != expectedOwner {
-
return &OwnerMismatch{
-
expected: expectedOwner,
-
observed: observedOwner,
-
}
-
}
-
-
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) {
-
tx, err := d.Begin()
-
if err != nil {
-
return 0, fmt.Errorf("failed to create txn: %w", err)
-
}
-
defer func() {
-
tx.Rollback()
-
e.E.LoadPolicy()
-
}()
-
-
// mark this spindle as verified in the db
-
rowId, err := db.VerifySpindle(
-
tx,
-
db.FilterEq("owner", owner),
-
db.FilterEq("instance", instance),
-
)
-
if err != nil {
-
return 0, fmt.Errorf("failed to write to DB: %w", err)
-
}
-
-
err = e.AddSpindleOwner(instance, owner)
-
if err != nil {
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
-
}
-
-
err = tx.Commit()
-
if err != nil {
-
return 0, fmt.Errorf("failed to commit txn: %w", err)
-
}
-
-
err = e.E.SavePolicy()
-
if err != nil {
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
-
}
-
-
return rowId, nil
-
}
···
+3 -3
appview/state/router.go
···
r.Mount("/settings", s.SettingsRouter())
r.Mount("/strings", s.StringsRouter(mw))
-
r.Mount("/knots", s.KnotsRouter(mw))
r.Mount("/spindles", s.SpindlesRouter())
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
···
return spindles.Router()
}
-
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
logger := log.New("knots")
knots := &knots.Knots{
···
Logger: logger,
}
-
return knots.Router(mw)
}
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
···
r.Mount("/settings", s.SettingsRouter())
r.Mount("/strings", s.StringsRouter(mw))
+
r.Mount("/knots", s.KnotsRouter())
r.Mount("/spindles", s.SpindlesRouter())
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
···
return spindles.Router()
}
+
func (s *State) KnotsRouter() http.Handler {
logger := log.New("knots")
knots := &knots.Knots{
···
Logger: logger,
}
+
return knots.Router()
}
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {