1package knots
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "slices"
9 "time"
10
11 "github.com/go-chi/chi/v5"
12 "tangled.sh/tangled.sh/core/api/tangled"
13 "tangled.sh/tangled.sh/core/appview/config"
14 "tangled.sh/tangled.sh/core/appview/db"
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
24 comatproto "github.com/bluesky-social/indigo/api/atproto"
25 lexutil "github.com/bluesky-social/indigo/lex/util"
26)
27
28type Knots struct {
29 Db *db.DB
30 OAuth *oauth.OAuth
31 Pages *pages.Pages
32 Config *config.Config
33 Enforcer *rbac.Enforcer
34 IdResolver *idresolver.Resolver
35 Logger *slog.Logger
36 Knotstream *eventconsumer.Consumer
37}
38
39func (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
55func (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{
65 LoggedInUser: user,
66 Registrations: registrations,
67 })
68}
69
70func (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 = ®
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
151func (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
266func (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
368func (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
452func (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
550func (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}