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 k.Pages.Knot(w, pages.KnotParams{
338 LoggedInUser: user,
339 Registration: reg,
340 Members: members,
341 Repos: repoByMember,
342 IsOwner: true,
343 })
344}
345
346// list members of domain, requires auth and requires owner status
347func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
348 l := k.Logger.With("handler", "members")
349
350 domain := chi.URLParam(r, "domain")
351 if domain == "" {
352 http.Error(w, "malformed url", http.StatusBadRequest)
353 return
354 }
355 l = l.With("domain", domain)
356
357 // list all members for this domain
358 memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
359 if err != nil {
360 w.Write([]byte("failed to fetch member list"))
361 return
362 }
363
364 w.Write([]byte(strings.Join(memberDids, "\n")))
365}
366
367// add member to domain, requires auth and requires invite access
368func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
369 l := k.Logger.With("handler", "members")
370
371 domain := chi.URLParam(r, "domain")
372 if domain == "" {
373 http.Error(w, "malformed url", http.StatusBadRequest)
374 return
375 }
376 l = l.With("domain", domain)
377
378 reg, err := db.RegistrationByDomain(k.Db, domain)
379 if err != nil {
380 l.Error("failed to get registration by domain", "err", err)
381 http.Error(w, "malformed url", http.StatusBadRequest)
382 return
383 }
384
385 noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
386 l = l.With("notice-id", noticeId)
387 defaultErr := "Failed to add member. Try again later."
388 fail := func() {
389 k.Pages.Notice(w, noticeId, defaultErr)
390 }
391
392 subjectIdentifier := r.FormValue("subject")
393 if subjectIdentifier == "" {
394 http.Error(w, "malformed form", http.StatusBadRequest)
395 return
396 }
397 l = l.With("subjectIdentifier", subjectIdentifier)
398
399 subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
400 if err != nil {
401 l.Error("failed to resolve identity", "err", err)
402 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
403 return
404 }
405 l = l.With("subjectDid", subjectIdentity.DID)
406
407 l.Info("adding member to knot")
408
409 // announce this relation into the firehose, store into owners' pds
410 client, err := k.OAuth.AuthorizedClient(r)
411 if err != nil {
412 l.Error("failed to create client", "err", err)
413 fail()
414 return
415 }
416
417 currentUser := k.OAuth.GetUser(r)
418 createdAt := time.Now().Format(time.RFC3339)
419 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
420 Collection: tangled.KnotMemberNSID,
421 Repo: currentUser.Did,
422 Rkey: tid.TID(),
423 Record: &lexutil.LexiconTypeDecoder{
424 Val: &tangled.KnotMember{
425 Subject: subjectIdentity.DID.String(),
426 Domain: domain,
427 CreatedAt: createdAt,
428 }},
429 })
430 // invalid record
431 if err != nil {
432 l.Error("failed to write to PDS", "err", err)
433 fail()
434 return
435 }
436 l = l.With("at-uri", resp.Uri)
437 l.Info("wrote record to PDS")
438
439 secret, err := db.GetRegistrationKey(k.Db, domain)
440 if err != nil {
441 l.Error("failed to get registration key", "err", err)
442 fail()
443 return
444 }
445
446 ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
447 if err != nil {
448 l.Error("failed to create client", "err", err)
449 fail()
450 return
451 }
452
453 ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
454 if err != nil {
455 l.Error("failed to reach knotserver", "err", err)
456 k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
457 return
458 }
459
460 if ksResp.StatusCode != http.StatusNoContent {
461 l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
462 k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
463 return
464 }
465
466 err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
467 if err != nil {
468 l.Error("failed to add member to enforcer", "err", err)
469 fail()
470 return
471 }
472
473 // success
474 k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
475}
476
477func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
478}