Monorepo for Tangled 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
··· 1 package knots 2 3 import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 "fmt" 9 "log/slog" 10 "net/http" 11 - "strings" 12 "time" 13 14 "github.com/go-chi/chi/v5" ··· 18 "tangled.sh/tangled.sh/core/appview/middleware" 19 "tangled.sh/tangled.sh/core/appview/oauth" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/eventconsumer" 22 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 "tangled.sh/tangled.sh/core/rbac" 25 "tangled.sh/tangled.sh/core/tid" 26 ··· 39 Knotstream *eventconsumer.Consumer 40 } 41 42 - func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 43 r := chi.NewRouter() 44 45 - r.Use(middleware.AuthMiddleware(k.OAuth)) 46 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 49 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 - }) 60 61 return r 62 } 63 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 - 68 user := k.OAuth.GetUser(r) 69 registrations, err := db.RegistrationsByDid(k.Db, user.Did) 70 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 72 } 73 74 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 }) 78 } 79 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 83 84 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 87 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 90 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 return 94 } 95 l = l.With("domain", domain) 96 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 100 } 101 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 103 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 return 107 } 108 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 110 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 113 return 114 } 115 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 121 }) 122 } 123 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") 127 user := k.OAuth.GetUser(r) 128 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 131 fail := func() { 132 k.Pages.Notice(w, noticeId, defaultErr) 133 } 134 135 - domain := chi.URLParam(r, "domain") 136 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 138 return 139 } 140 l = l.With("domain", domain) 141 142 - l.Info("checking domain") 143 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 145 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 147 fail() 148 return 149 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 153 return 154 } 155 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 157 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 159 fail() 160 return 161 } 162 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 164 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 166 fail() 167 return 168 } 169 170 - resp, err := client.Init(user.Did) 171 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) 174 return 175 } 176 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) 180 return 181 } 182 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) 186 return 187 } 188 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 192 if err != nil { 193 return 194 } 195 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 198 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) 202 return 203 } 204 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 206 if err != nil { 207 - l.Error("failed to start tx", "err", err) 208 fail() 209 return 210 } 211 defer func() { 212 tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 217 }() 218 219 - // mark as registered 220 - err = db.Register(tx, domain) 221 if err != nil { 222 - l.Error("failed to register domain", "err", err) 223 fail() 224 return 225 } 226 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 233 } 234 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 237 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 239 fail() 240 return 241 } 242 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 245 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 - fail() 248 - return 249 } 250 251 err = tx.Commit() 252 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 254 fail() 255 return 256 } 257 258 err = k.Enforcer.E.SavePolicy() 259 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 262 return 263 } 264 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 270 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 274 } 275 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 278 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 280 } 281 282 domain := chi.URLParam(r, "domain") 283 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 285 return 286 } 287 l = l.With("domain", domain) 288 - 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) 294 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 296 fail() 297 - } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 300 return 301 } 302 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 304 - if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 306 - fail() 307 return 308 } 309 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 313 - if err != nil { 314 - l.Error("failed to get members list", "err", err) 315 - fail() 316 return 317 } 318 - } 319 320 - repos, err := db.GetRepos( 321 - k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 325 - ) 326 - if err != nil { 327 - l.Error("failed to get repos list", "err", err) 328 fail() 329 return 330 } 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 - var didsToResolve []string 338 - for _, m := range members { 339 - didsToResolve = append(didsToResolve, m) 340 - } 341 - didsToResolve = append(didsToResolve, reg.ByDid) 342 - resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve) 343 - didHandleMap := make(map[string]string) 344 - for _, identity := range resolvedIds { 345 - if !identity.Handle.IsInvalidHandle() { 346 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 347 - } else { 348 - didHandleMap[identity.DID.String()] = identity.DID.String() 349 - } 350 } 351 352 - k.Pages.Knot(w, pages.KnotParams{ 353 - LoggedInUser: user, 354 - DidHandleMap: didHandleMap, 355 - Registration: reg, 356 - Members: members, 357 - Repos: repoByMember, 358 - IsOwner: true, 359 - }) 360 - } 361 362 - // list members of domain, requires auth and requires owner status 363 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 - l := k.Logger.With("handler", "members") 365 - 366 - domain := chi.URLParam(r, "domain") 367 - if domain == "" { 368 - http.Error(w, "malformed url", http.StatusBadRequest) 369 return 370 } 371 - l = l.With("domain", domain) 372 373 - // list all members for this domain 374 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 375 if err != nil { 376 - w.Write([]byte("failed to fetch member list")) 377 return 378 } 379 380 - w.Write([]byte(strings.Join(memberDids, "\n"))) 381 } 382 383 - // add member to domain, requires auth and requires invite access 384 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 385 - l := k.Logger.With("handler", "members") 386 387 domain := chi.URLParam(r, "domain") 388 if domain == "" { 389 - http.Error(w, "malformed url", http.StatusBadRequest) 390 return 391 } 392 l = l.With("domain", domain) 393 394 - reg, err := db.RegistrationByDomain(k.Db, domain) 395 if err != nil { 396 - l.Error("failed to get registration by domain", "err", err) 397 - http.Error(w, "malformed url", http.StatusBadRequest) 398 return 399 } 400 401 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 402 - l = l.With("notice-id", noticeId) 403 defaultErr := "Failed to add member. Try again later." 404 fail := func() { 405 k.Pages.Notice(w, noticeId, defaultErr) 406 } 407 408 - subjectIdentifier := r.FormValue("subject") 409 - if subjectIdentifier == "" { 410 - http.Error(w, "malformed form", http.StatusBadRequest) 411 return 412 } 413 - l = l.With("subjectIdentifier", subjectIdentifier) 414 415 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 416 if err != nil { 417 - l.Error("failed to resolve identity", "err", err) 418 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 419 return 420 } 421 - l = l.With("subjectDid", subjectIdentity.DID) 422 423 - l.Info("adding member to knot") 424 - 425 - // announce this relation into the firehose, store into owners' pds 426 client, err := k.OAuth.AuthorizedClient(r) 427 if err != nil { 428 - l.Error("failed to create client", "err", err) 429 fail() 430 return 431 } 432 433 - currentUser := k.OAuth.GetUser(r) 434 - createdAt := time.Now().Format(time.RFC3339) 435 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 Collection: tangled.KnotMemberNSID, 437 - Repo: currentUser.Did, 438 - Rkey: tid.TID(), 439 Record: &lexutil.LexiconTypeDecoder{ 440 Val: &tangled.KnotMember{ 441 - Subject: subjectIdentity.DID.String(), 442 Domain: domain, 443 - CreatedAt: createdAt, 444 - }}, 445 }) 446 - // invalid record 447 if err != nil { 448 - l.Error("failed to write to PDS", "err", err) 449 fail() 450 return 451 } 452 - l = l.With("at-uri", resp.Uri) 453 - l.Info("wrote record to PDS") 454 455 - secret, err := db.GetRegistrationKey(k.Db, domain) 456 if err != nil { 457 - l.Error("failed to get registration key", "err", err) 458 fail() 459 return 460 } 461 462 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 463 if err != nil { 464 - l.Error("failed to create client", "err", err) 465 fail() 466 return 467 } 468 469 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 470 if err != nil { 471 - l.Error("failed to reach knotserver", "err", err) 472 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 473 return 474 } 475 476 - if ksResp.StatusCode != http.StatusNoContent { 477 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 478 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 479 return 480 } 481 482 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 483 if err != nil { 484 - l.Error("failed to add member to enforcer", "err", err) 485 fail() 486 return 487 } 488 489 - // success 490 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 491 - } 492 493 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 494 }
··· 1 package knots 2 3 import ( 4 + "errors" 5 "fmt" 6 "log/slog" 7 "net/http" 8 + "slices" 9 "time" 10 11 "github.com/go-chi/chi/v5" ··· 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 "tangled.sh/tangled.sh/core/eventconsumer" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 "tangled.sh/tangled.sh/core/tid" 23 ··· 36 Knotstream *eventconsumer.Consumer 37 } 38 39 + func (k *Knots) Router() http.Handler { 40 r := chi.NewRouter() 41 42 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 44 45 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 47 48 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 51 52 return r 53 } 54 55 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 56 user := k.OAuth.GetUser(r) 57 registrations, err := db.RegistrationsByDid(k.Db, user.Did) 58 if err != nil { 59 + k.Logger.Error("failed to fetch knot registrations", "err", err) 60 + w.WriteHeader(http.StatusInternalServerError) 61 + return 62 } 63 64 k.Pages.Knots(w, pages.KnotsParams{ ··· 67 }) 68 } 69 70 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 71 + l := k.Logger.With("handler", "dashboard") 72 73 user := k.OAuth.GetUser(r) 74 + l = l.With("user", user.Did) 75 76 + domain := chi.URLParam(r, "domain") 77 if domain == "" { 78 return 79 } 80 l = l.With("domain", domain) 81 82 + registrations, err := db.RegistrationsByDid(k.Db, user.Did) 83 + if err != nil { 84 + l.Error("failed to get registrations", "err", err) 85 + http.Error(w, "Not found", http.StatusNotFound) 86 + return 87 } 88 89 + // Find the specific registration for this domain 90 + var registration *db.Registration 91 + for _, reg := range registrations { 92 + if reg.Domain == domain && reg.ByDid == user.Did && reg.Registered != nil { 93 + registration = &reg 94 + break 95 + } 96 + } 97 + 98 + if registration == nil { 99 + l.Error("registration not found or not verified") 100 + http.Error(w, "Not found", http.StatusNotFound) 101 + return 102 + } 103 + 104 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 105 if err != nil { 106 + l.Error("failed to get knot members", "err", err) 107 + http.Error(w, "Not found", http.StatusInternalServerError) 108 return 109 } 110 + slices.Sort(members) 111 112 + repos, err := db.GetRepos( 113 + k.Db, 114 + 0, 115 + db.FilterEq("knot", domain), 116 + ) 117 if err != nil { 118 + l.Error("failed to get knot repos", "err", err) 119 + http.Error(w, "Not found", http.StatusInternalServerError) 120 return 121 } 122 123 + identsToResolve := make([]string, len(members)) 124 + copy(identsToResolve, members) 125 + resolvedIds := k.IdResolver.ResolveIdents(r.Context(), identsToResolve) 126 + didHandleMap := make(map[string]string) 127 + for _, identity := range resolvedIds { 128 + if !identity.Handle.IsInvalidHandle() { 129 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 130 + } else { 131 + didHandleMap[identity.DID.String()] = identity.DID.String() 132 + } 133 + } 134 + 135 + // organize repos by did 136 + repoMap := make(map[string][]db.Repo) 137 + for _, r := range repos { 138 + repoMap[r.Did] = append(repoMap[r.Did], r) 139 + } 140 + 141 + k.Pages.Knot(w, pages.KnotParams{ 142 + LoggedInUser: user, 143 + Registration: registration, 144 + Members: members, 145 + Repos: repoMap, 146 + DidHandleMap: didHandleMap, 147 + IsOwner: true, 148 }) 149 } 150 151 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 152 user := k.OAuth.GetUser(r) 153 + l := k.Logger.With("handler", "register") 154 155 + noticeId := "register-error" 156 + defaultErr := "Failed to register knot. Try again later." 157 fail := func() { 158 k.Pages.Notice(w, noticeId, defaultErr) 159 } 160 161 + domain := r.FormValue("domain") 162 if domain == "" { 163 + k.Pages.Notice(w, noticeId, "Incomplete form.") 164 return 165 } 166 l = l.With("domain", domain) 167 + l = l.With("user", user.Did) 168 169 + tx, err := k.Db.Begin() 170 + if err != nil { 171 + l.Error("failed to start transaction", "err", err) 172 + fail() 173 + return 174 + } 175 + defer func() { 176 + tx.Rollback() 177 + k.Enforcer.E.LoadPolicy() 178 + }() 179 180 + err = db.AddKnot(tx, domain, user.Did) 181 if err != nil { 182 + l.Error("failed to insert", "err", err) 183 fail() 184 return 185 } 186 + 187 + err = k.Enforcer.AddKnot(domain) 188 + if err != nil { 189 + l.Error("failed to create knot", "err", err) 190 + fail() 191 return 192 } 193 194 + // create record on pds 195 + client, err := k.OAuth.AuthorizedClient(r) 196 if err != nil { 197 + l.Error("failed to authorize client", "err", err) 198 fail() 199 return 200 } 201 202 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 203 + var exCid *string 204 + if ex != nil { 205 + exCid = ex.Cid 206 + } 207 + 208 + // re-announce by registering under same rkey 209 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 210 + Collection: tangled.KnotNSID, 211 + Repo: user.Did, 212 + Rkey: domain, 213 + Record: &lexutil.LexiconTypeDecoder{ 214 + Val: &tangled.Knot{ 215 + CreatedAt: time.Now().Format(time.RFC3339), 216 + }, 217 + }, 218 + SwapRecord: exCid, 219 + }) 220 + 221 if err != nil { 222 + l.Error("failed to put record", "err", err) 223 fail() 224 return 225 } 226 227 + err = tx.Commit() 228 if err != nil { 229 + l.Error("failed to commit transaction", "err", err) 230 + fail() 231 return 232 } 233 234 + err = k.Enforcer.E.SavePolicy() 235 + if err != nil { 236 + l.Error("failed to update ACL", "err", err) 237 + k.Pages.HxRefresh(w) 238 return 239 } 240 241 + // begin verification 242 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 243 + if err != nil { 244 + l.Error("verification failed", "err", err) 245 + k.Pages.HxRefresh(w) 246 return 247 } 248 249 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 250 if err != nil { 251 + l.Error("failed to mark verified", "err", err) 252 + k.Pages.HxRefresh(w) 253 return 254 } 255 256 + // add this knot to knotstream 257 + go k.Knotstream.AddSource( 258 + r.Context(), 259 + eventconsumer.NewKnotSource(domain), 260 + ) 261 + 262 + // ok 263 + k.Pages.HxRefresh(w) 264 + } 265 + 266 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 267 + user := k.OAuth.GetUser(r) 268 + l := k.Logger.With("handler", "delete") 269 + 270 + noticeId := "operation-error" 271 + defaultErr := "Failed to delete knot. Try again later." 272 + fail := func() { 273 + k.Pages.Notice(w, noticeId, defaultErr) 274 + } 275 276 + domain := chi.URLParam(r, "domain") 277 + if domain == "" { 278 + l.Error("empty domain") 279 + fail() 280 return 281 } 282 283 + registration, err := db.RegistrationByDomain(k.Db, domain) 284 if err != nil { 285 + l.Error("failed to retrieve domain registration", "err", err) 286 + fail() 287 + return 288 + } 289 + 290 + if registration.ByDid != user.Did { 291 + l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid) 292 + k.Pages.Notice(w, noticeId, "Failed to delete knot, unauthorized deletion attempt.") 293 + return 294 + } 295 + 296 + tx, err := k.Db.Begin() 297 + if err != nil { 298 + l.Error("failed to start txn", "err", err) 299 fail() 300 return 301 } 302 defer func() { 303 tx.Rollback() 304 + k.Enforcer.E.LoadPolicy() 305 }() 306 307 + err = db.DeleteKnot( 308 + tx, 309 + db.FilterEq("did", user.Did), 310 + db.FilterEq("domain", domain), 311 + ) 312 if err != nil { 313 + l.Error("failed to delete registration", "err", err) 314 fail() 315 return 316 } 317 318 + // delete from enforcer if it was registered 319 + if registration.Registered != nil { 320 + err = k.Enforcer.RemoveKnot(domain) 321 + if err != nil { 322 + l.Error("failed to update ACL", "err", err) 323 + fail() 324 + return 325 + } 326 } 327 328 + client, err := k.OAuth.AuthorizedClient(r) 329 if err != nil { 330 + l.Error("failed to authorize client", "err", err) 331 fail() 332 return 333 } 334 335 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 + Collection: tangled.KnotNSID, 337 + Repo: user.Did, 338 + Rkey: domain, 339 + }) 340 if err != nil { 341 + // non-fatal 342 + l.Error("failed to delete record", "err", err) 343 } 344 345 err = tx.Commit() 346 if err != nil { 347 + l.Error("failed to delete knot", "err", err) 348 fail() 349 return 350 } 351 352 err = k.Enforcer.E.SavePolicy() 353 if err != nil { 354 + l.Error("failed to update ACL", "err", err) 355 + k.Pages.HxRefresh(w) 356 return 357 } 358 359 + shouldRedirect := r.Header.Get("shouldRedirect") 360 + if shouldRedirect == "true" { 361 + k.Pages.HxRedirect(w, "/knots") 362 + return 363 + } 364 365 + w.Write([]byte{}) 366 } 367 368 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 369 + user := k.OAuth.GetUser(r) 370 + l := k.Logger.With("handler", "retry") 371 + 372 + noticeId := "operation-error" 373 + defaultErr := "Failed to verify knot. Try again later." 374 fail := func() { 375 + k.Pages.Notice(w, noticeId, defaultErr) 376 } 377 378 domain := chi.URLParam(r, "domain") 379 if domain == "" { 380 + l.Error("empty domain") 381 + fail() 382 return 383 } 384 l = l.With("domain", domain) 385 + l = l.With("user", user.Did) 386 387 + registration, err := db.RegistrationByDomain(k.Db, domain) 388 if err != nil { 389 + l.Error("failed to retrieve domain registration", "err", err) 390 fail() 391 return 392 } 393 394 + if registration.ByDid != user.Did { 395 + l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid) 396 + k.Pages.Notice(w, noticeId, "Failed to verify knot, unauthorized verification attempt.") 397 return 398 } 399 400 + // begin verification 401 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 402 + if err != nil { 403 + l.Error("verification failed", "err", err) 404 + 405 + if errors.Is(err, serververify.FetchError) { 406 + k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 407 + return 408 + } 409 + 410 + if e, ok := err.(*serververify.OwnerMismatch); ok { 411 + k.Pages.Notice(w, noticeId, e.Error()) 412 return 413 } 414 415 fail() 416 return 417 } 418 419 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 420 + if err != nil { 421 + l.Error("failed to mark verified", "err", err) 422 + k.Pages.Notice(w, noticeId, err.Error()) 423 + return 424 } 425 426 + // add this knot to knotstream 427 + go k.Knotstream.AddSource( 428 + r.Context(), 429 + eventconsumer.NewKnotSource(domain), 430 + ) 431 432 + shouldRefresh := r.Header.Get("shouldRefresh") 433 + if shouldRefresh == "true" { 434 + k.Pages.HxRefresh(w) 435 return 436 } 437 438 + // Get updated registration to show 439 + updatedRegistration, err := db.RegistrationByDomain(k.Db, domain) 440 if err != nil { 441 + l.Error("failed get updated registration", "err", err) 442 + k.Pages.HxRefresh(w) 443 return 444 } 445 446 + w.Header().Set("HX-Reswap", "outerHTML") 447 + k.Pages.KnotListing(w, pages.KnotListingParams{ 448 + Registration: *updatedRegistration, 449 + }) 450 } 451 452 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 453 + user := k.OAuth.GetUser(r) 454 + l := k.Logger.With("handler", "addMember") 455 456 domain := chi.URLParam(r, "domain") 457 if domain == "" { 458 + l.Error("empty domain") 459 + http.Error(w, "Not found", http.StatusNotFound) 460 return 461 } 462 l = l.With("domain", domain) 463 + l = l.With("user", user.Did) 464 465 + registration, err := db.RegistrationByDomain(k.Db, domain) 466 if err != nil { 467 + l.Error("failed to retrieve domain registration", "err", err) 468 + http.Error(w, "Not found", http.StatusNotFound) 469 return 470 } 471 472 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 473 defaultErr := "Failed to add member. Try again later." 474 fail := func() { 475 k.Pages.Notice(w, noticeId, defaultErr) 476 } 477 478 + if registration.ByDid != user.Did { 479 + l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid) 480 + k.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 481 return 482 } 483 484 + member := r.FormValue("member") 485 + if member == "" { 486 + l.Error("empty member") 487 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 488 + return 489 + } 490 + l = l.With("member", member) 491 + 492 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 493 if err != nil { 494 + l.Error("failed to resolve member identity to handle", "err", err) 495 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 496 + return 497 + } 498 + if memberId.Handle.IsInvalidHandle() { 499 + l.Error("failed to resolve member identity to handle") 500 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 501 return 502 } 503 504 + // write to pds 505 client, err := k.OAuth.AuthorizedClient(r) 506 if err != nil { 507 + l.Error("failed to authorize client", "err", err) 508 fail() 509 return 510 } 511 512 + rkey := tid.TID() 513 + 514 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 515 Collection: tangled.KnotMemberNSID, 516 + Repo: user.Did, 517 + Rkey: rkey, 518 Record: &lexutil.LexiconTypeDecoder{ 519 Val: &tangled.KnotMember{ 520 + CreatedAt: time.Now().Format(time.RFC3339), 521 Domain: domain, 522 + Subject: memberId.DID.String(), 523 + }, 524 + }, 525 }) 526 if err != nil { 527 + l.Error("failed to add record to PDS", "err", err) 528 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 529 + return 530 + } 531 + 532 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 533 + if err != nil { 534 + l.Error("failed to add member to ACLs", "err", err) 535 fail() 536 return 537 } 538 539 + err = k.Enforcer.E.SavePolicy() 540 if err != nil { 541 + l.Error("failed to save ACL policy", "err", err) 542 + fail() 543 + return 544 + } 545 + 546 + // success 547 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 548 + } 549 + 550 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 551 + user := k.OAuth.GetUser(r) 552 + l := k.Logger.With("handler", "removeMember") 553 + 554 + noticeId := "operation-error" 555 + defaultErr := "Failed to remove member. Try again later." 556 + fail := func() { 557 + k.Pages.Notice(w, noticeId, defaultErr) 558 + } 559 + 560 + domain := chi.URLParam(r, "domain") 561 + if domain == "" { 562 + l.Error("empty domain") 563 fail() 564 return 565 } 566 + l = l.With("domain", domain) 567 + l = l.With("user", user.Did) 568 569 + registration, err := db.RegistrationByDomain(k.Db, domain) 570 if err != nil { 571 + l.Error("failed to retrieve domain registration", "err", err) 572 fail() 573 return 574 } 575 576 + if registration.ByDid != user.Did { 577 + l.Error("unauthorized", "user", user.Did, "owner", registration.ByDid) 578 + k.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 579 + return 580 + } 581 + 582 + member := r.FormValue("member") 583 + if member == "" { 584 + l.Error("empty member") 585 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 586 + return 587 + } 588 + l = l.With("member", member) 589 + 590 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 591 if err != nil { 592 + l.Error("failed to resolve member identity to handle", "err", err) 593 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 594 + return 595 + } 596 + if memberId.Handle.IsInvalidHandle() { 597 + l.Error("failed to resolve member identity to handle") 598 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 599 return 600 } 601 602 + // remove from enforcer 603 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 604 + if err != nil { 605 + l.Error("failed to update ACLs", "err", err) 606 + fail() 607 return 608 } 609 610 + client, err := k.OAuth.AuthorizedClient(r) 611 if err != nil { 612 + l.Error("failed to authorize client", "err", err) 613 fail() 614 return 615 } 616 617 + // TODO: We need to track the rkey for knot members to delete the record 618 + // For now, just remove from ACLs 619 + _ = client 620 621 + // commit everything 622 + err = k.Enforcer.E.SavePolicy() 623 + if err != nil { 624 + l.Error("failed to save ACLs", "err", err) 625 + fail() 626 + return 627 + } 628 + 629 + // ok 630 + k.Pages.HxRefresh(w) 631 }
+160
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.Register(tx, domain) 133 + if err != nil { 134 + return fmt.Errorf("failed to register domain: %w", err) 135 + } 136 + 137 + // add basic acls for this domain 138 + err = e.AddKnot(domain) 139 + if err != nil { 140 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 141 + } 142 + 143 + // add this did as owner of this domain 144 + err = e.AddKnotOwner(domain, owner) 145 + if err != nil { 146 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 147 + } 148 + 149 + err = tx.Commit() 150 + if err != nil { 151 + return fmt.Errorf("failed to commit changes: %w", err) 152 + } 153 + 154 + err = e.E.SavePolicy() 155 + if err != nil { 156 + return fmt.Errorf("failed to update ACLs: %w", err) 157 + } 158 + 159 + return nil 160 + }
+8 -8
appview/spindles/spindles.go
··· 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 240 } 241 242 // begin verification 243 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 if err != nil { 245 l.Error("verification failed", "err", err) 246 s.Pages.HxRefresh(w) 247 return 248 } 249 250 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 251 if err != nil { 252 l.Error("failed to mark verified", "err", err) 253 s.Pages.HxRefresh(w) ··· 413 } 414 415 // begin verification 416 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 417 if err != nil { 418 l.Error("verification failed", "err", err) 419 420 - if errors.Is(err, verify.FetchError) { 421 - s.Pages.Notice(w, noticeId, err.Error()) 422 return 423 } 424 425 - if e, ok := err.(*verify.OwnerMismatch); ok { 426 s.Pages.Notice(w, noticeId, e.Error()) 427 return 428 } ··· 431 return 432 } 433 434 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 435 if err != nil { 436 l.Error("failed to mark verified", "err", err) 437 s.Pages.Notice(w, noticeId, err.Error())
··· 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 240 } 241 242 // begin verification 243 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 if err != nil { 245 l.Error("verification failed", "err", err) 246 s.Pages.HxRefresh(w) 247 return 248 } 249 250 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 251 if err != nil { 252 l.Error("failed to mark verified", "err", err) 253 s.Pages.HxRefresh(w) ··· 413 } 414 415 // begin verification 416 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 417 if err != nil { 418 l.Error("verification failed", "err", err) 419 420 + if errors.Is(err, serververify.FetchError) { 421 + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 422 return 423 } 424 425 + if e, ok := err.(*serververify.OwnerMismatch); ok { 426 s.Pages.Notice(w, noticeId, e.Error()) 427 return 428 } ··· 431 return 432 } 433 434 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 435 if err != nil { 436 l.Error("failed to mark verified", "err", err) 437 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 - }
···
+3 -3
appview/state/router.go
··· 136 137 r.Mount("/settings", s.SettingsRouter()) 138 r.Mount("/strings", s.StringsRouter(mw)) 139 - r.Mount("/knots", s.KnotsRouter(mw)) 140 r.Mount("/spindles", s.SpindlesRouter()) 141 r.Mount("/signup", s.SignupRouter()) 142 r.Mount("/", s.OAuthRouter()) ··· 184 return spindles.Router() 185 } 186 187 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 188 logger := log.New("knots") 189 190 knots := &knots.Knots{ ··· 198 Logger: logger, 199 } 200 201 - return knots.Router(mw) 202 } 203 204 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
··· 136 137 r.Mount("/settings", s.SettingsRouter()) 138 r.Mount("/strings", s.StringsRouter(mw)) 139 + r.Mount("/knots", s.KnotsRouter()) 140 r.Mount("/spindles", s.SpindlesRouter()) 141 r.Mount("/signup", s.SignupRouter()) 142 r.Mount("/", s.OAuthRouter()) ··· 184 return spindles.Router() 185 } 186 187 + func (s *State) KnotsRouter() http.Handler { 188 logger := log.New("knots") 189 190 knots := &knots.Knots{ ··· 198 Logger: logger, 199 } 200 201 + return knots.Router() 202 } 203 204 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {