1package knots
2
3import (
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"
15 "tangled.sh/tangled.sh/core/api/tangled"
16 "tangled.sh/tangled.sh/core/appview/config"
17 "tangled.sh/tangled.sh/core/appview/db"
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
27 comatproto "github.com/bluesky-social/indigo/api/atproto"
28 lexutil "github.com/bluesky-social/indigo/lex/util"
29)
30
31type Knots struct {
32 Db *db.DB
33 OAuth *oauth.OAuth
34 Pages *pages.Pages
35 Config *config.Config
36 Enforcer *rbac.Enforcer
37 IdResolver *idresolver.Resolver
38 Logger *slog.Logger
39 Knotstream *eventconsumer.Consumer
40}
41
42func (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
65func (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{
75 LoggedInUser: user,
76 Registrations: registrations,
77 })
78}
79
80// requires auth
81func (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
125func (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
276func (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
363func (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
384func (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
493func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
494}