this repo has no description
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"
17 "tangled.sh/tangled.sh/core/appview/config"
18 "tangled.sh/tangled.sh/core/appview/db"
19 "tangled.sh/tangled.sh/core/appview/idresolver"
20 "tangled.sh/tangled.sh/core/appview/middleware"
21 "tangled.sh/tangled.sh/core/appview/oauth"
22 "tangled.sh/tangled.sh/core/appview/pages"
23 "tangled.sh/tangled.sh/core/eventconsumer"
24 "tangled.sh/tangled.sh/core/knotclient"
25 "tangled.sh/tangled.sh/core/rbac"
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 secret, err := db.GetRegistrationKey(k.Db, domain)
145 if err != nil {
146 l.Error("failed to get registration key for domain", "err", err)
147 fail()
148 return
149 }
150
151 client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
152 if err != nil {
153 l.Error("failed to create knotclient", "err", err)
154 fail()
155 return
156 }
157
158 resp, err := client.Init(user.Did)
159 if err != nil {
160 k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error()))
161 l.Error("failed to make init request", "err", err)
162 return
163 }
164
165 if resp.StatusCode == http.StatusConflict {
166 k.Pages.Notice(w, noticeId, "This knot is already registered")
167 l.Error("knot already registered", "statuscode", resp.StatusCode)
168 return
169 }
170
171 if resp.StatusCode != http.StatusNoContent {
172 k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent))
173 l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent)
174 return
175 }
176
177 // verify response mac
178 signature := resp.Header.Get("X-Signature")
179 signatureBytes, err := hex.DecodeString(signature)
180 if err != nil {
181 return
182 }
183
184 expectedMac := hmac.New(sha256.New, []byte(secret))
185 expectedMac.Write([]byte("ok"))
186
187 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
188 k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.")
189 l.Error("signature mismatch", "bytes", signatureBytes)
190 return
191 }
192
193 tx, err := k.Db.BeginTx(r.Context(), nil)
194 if err != nil {
195 l.Error("failed to start tx", "err", err)
196 fail()
197 return
198 }
199 defer func() {
200 tx.Rollback()
201 err = k.Enforcer.E.LoadPolicy()
202 if err != nil {
203 l.Error("rollback failed", "err", err)
204 }
205 }()
206
207 // mark as registered
208 err = db.Register(tx, domain)
209 if err != nil {
210 l.Error("failed to register domain", "err", err)
211 fail()
212 return
213 }
214
215 // set permissions for this did as owner
216 reg, err := db.RegistrationByDomain(tx, domain)
217 if err != nil {
218 l.Error("failed get registration by domain", "err", err)
219 fail()
220 return
221 }
222
223 // add basic acls for this domain
224 err = k.Enforcer.AddKnot(domain)
225 if err != nil {
226 l.Error("failed to add knot to enforcer", "err", err)
227 fail()
228 return
229 }
230
231 // add this did as owner of this domain
232 err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
233 if err != nil {
234 l.Error("failed to add knot owner to enforcer", "err", err)
235 fail()
236 return
237 }
238
239 err = tx.Commit()
240 if err != nil {
241 l.Error("failed to commit changes", "err", err)
242 fail()
243 return
244 }
245
246 err = k.Enforcer.E.SavePolicy()
247 if err != nil {
248 l.Error("failed to update ACLs", "err", err)
249 fail()
250 return
251 }
252
253 // add this knot to knotstream
254 go k.Knotstream.AddSource(
255 context.Background(),
256 eventconsumer.NewKnotSource(domain),
257 )
258
259 k.Pages.KnotListing(w, pages.KnotListingParams{
260 Registration: *reg,
261 })
262}
263
264func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
265 l := k.Logger.With("handler", "dashboard")
266 fail := func() {
267 w.WriteHeader(http.StatusInternalServerError)
268 }
269
270 domain := chi.URLParam(r, "domain")
271 if domain == "" {
272 http.Error(w, "malformed url", http.StatusBadRequest)
273 return
274 }
275 l = l.With("domain", domain)
276
277 user := k.OAuth.GetUser(r)
278 l = l.With("did", user.Did)
279
280 // dashboard is only available to owners
281 ok, err := k.Enforcer.IsKnotOwner(user.Did, domain)
282 if err != nil {
283 l.Error("failed to query enforcer", "err", err)
284 fail()
285 }
286 if !ok {
287 http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
288 return
289 }
290
291 reg, err := db.RegistrationByDomain(k.Db, domain)
292 if err != nil {
293 l.Error("failed to get registration by domain", "err", err)
294 fail()
295 return
296 }
297
298 var members []string
299 if reg.Registered != nil {
300 members, err = k.Enforcer.GetUserByRole("server:member", domain)
301 if err != nil {
302 l.Error("failed to get members list", "err", err)
303 fail()
304 return
305 }
306 }
307
308 repos, err := db.GetRepos(
309 k.Db,
310 db.FilterEq("knot", domain),
311 db.FilterIn("did", members),
312 )
313 if err != nil {
314 l.Error("failed to get repos list", "err", err)
315 fail()
316 return
317 }
318 // convert to map
319 repoByMember := make(map[string][]db.Repo)
320 for _, r := range repos {
321 repoByMember[r.Did] = append(repoByMember[r.Did], r)
322 }
323
324 var didsToResolve []string
325 for _, m := range members {
326 didsToResolve = append(didsToResolve, m)
327 }
328 didsToResolve = append(didsToResolve, reg.ByDid)
329 resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve)
330 didHandleMap := make(map[string]string)
331 for _, identity := range resolvedIds {
332 if !identity.Handle.IsInvalidHandle() {
333 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
334 } else {
335 didHandleMap[identity.DID.String()] = identity.DID.String()
336 }
337 }
338
339 k.Pages.Knot(w, pages.KnotParams{
340 LoggedInUser: user,
341 DidHandleMap: didHandleMap,
342 Registration: reg,
343 Members: members,
344 Repos: repoByMember,
345 IsOwner: true,
346 })
347}
348
349// list members of domain, requires auth and requires owner status
350func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
351 l := k.Logger.With("handler", "members")
352
353 domain := chi.URLParam(r, "domain")
354 if domain == "" {
355 http.Error(w, "malformed url", http.StatusBadRequest)
356 return
357 }
358 l = l.With("domain", domain)
359
360 // list all members for this domain
361 memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
362 if err != nil {
363 w.Write([]byte("failed to fetch member list"))
364 return
365 }
366
367 w.Write([]byte(strings.Join(memberDids, "\n")))
368 return
369}
370
371// add member to domain, requires auth and requires invite access
372func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
373 l := k.Logger.With("handler", "members")
374
375 domain := chi.URLParam(r, "domain")
376 if domain == "" {
377 http.Error(w, "malformed url", http.StatusBadRequest)
378 return
379 }
380 l = l.With("domain", domain)
381
382 reg, err := db.RegistrationByDomain(k.Db, domain)
383 if err != nil {
384 l.Error("failed to get registration by domain", "err", err)
385 http.Error(w, "malformed url", http.StatusBadRequest)
386 return
387 }
388
389 noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
390 l = l.With("notice-id", noticeId)
391 defaultErr := "Failed to add member. Try again later."
392 fail := func() {
393 k.Pages.Notice(w, noticeId, defaultErr)
394 }
395
396 subjectIdentifier := r.FormValue("subject")
397 if subjectIdentifier == "" {
398 http.Error(w, "malformed form", http.StatusBadRequest)
399 return
400 }
401 l = l.With("subjectIdentifier", subjectIdentifier)
402
403 subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
404 if err != nil {
405 l.Error("failed to resolve identity", "err", err)
406 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
407 return
408 }
409 l = l.With("subjectDid", subjectIdentity.DID)
410
411 l.Info("adding member to knot")
412
413 // announce this relation into the firehose, store into owners' pds
414 client, err := k.OAuth.AuthorizedClient(r)
415 if err != nil {
416 l.Error("failed to create client", "err", err)
417 fail()
418 return
419 }
420
421 currentUser := k.OAuth.GetUser(r)
422 createdAt := time.Now().Format(time.RFC3339)
423 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
424 Collection: tangled.KnotMemberNSID,
425 Repo: currentUser.Did,
426 Rkey: appview.TID(),
427 Record: &lexutil.LexiconTypeDecoder{
428 Val: &tangled.KnotMember{
429 Subject: subjectIdentity.DID.String(),
430 Domain: domain,
431 CreatedAt: createdAt,
432 }},
433 })
434 // invalid record
435 if err != nil {
436 l.Error("failed to write to PDS", "err", err)
437 fail()
438 return
439 }
440 l = l.With("at-uri", resp.Uri)
441 l.Info("wrote record to PDS")
442
443 secret, err := db.GetRegistrationKey(k.Db, domain)
444 if err != nil {
445 l.Error("failed to get registration key", "err", err)
446 fail()
447 return
448 }
449
450 ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
451 if err != nil {
452 l.Error("failed to create client", "err", err)
453 fail()
454 return
455 }
456
457 ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
458 if err != nil {
459 l.Error("failed to reach knotserver", "err", err)
460 k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
461 return
462 }
463
464 if ksResp.StatusCode != http.StatusNoContent {
465 l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
466 k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
467 return
468 }
469
470 err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
471 if err != nil {
472 l.Error("failed to add member to enforcer", "err", err)
473 fail()
474 return
475 }
476
477 // success
478 k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
479}
480
481func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
482}