this repo has no description
1package state
2
3import (
4 "context"
5 "crypto/hmac"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "log"
10 "log/slog"
11 "net/http"
12 "strings"
13 "time"
14
15 comatproto "github.com/bluesky-social/indigo/api/atproto"
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 lexutil "github.com/bluesky-social/indigo/lex/util"
18 securejoin "github.com/cyphar/filepath-securejoin"
19 "github.com/go-chi/chi/v5"
20 "github.com/posthog/posthog-go"
21 "tangled.sh/tangled.sh/core/api/tangled"
22 "tangled.sh/tangled.sh/core/appview"
23 "tangled.sh/tangled.sh/core/appview/db"
24 "tangled.sh/tangled.sh/core/appview/idresolver"
25 "tangled.sh/tangled.sh/core/appview/oauth"
26 "tangled.sh/tangled.sh/core/appview/pages"
27 "tangled.sh/tangled.sh/core/appview/reporesolver"
28 "tangled.sh/tangled.sh/core/jetstream"
29 "tangled.sh/tangled.sh/core/knotclient"
30 "tangled.sh/tangled.sh/core/rbac"
31)
32
33type State struct {
34 db *db.DB
35 oauth *oauth.OAuth
36 enforcer *rbac.Enforcer
37 tidClock syntax.TIDClock
38 pages *pages.Pages
39 idResolver *idresolver.Resolver
40 posthog posthog.Client
41 jc *jetstream.JetstreamClient
42 config *appview.Config
43 repoResolver *reporesolver.RepoResolver
44}
45
46func Make(config *appview.Config) (*State, error) {
47 d, err := db.Make(config.Core.DbPath)
48 if err != nil {
49 return nil, err
50 }
51
52 enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
53 if err != nil {
54 return nil, err
55 }
56
57 clock := syntax.NewTIDClock(0)
58
59 pgs := pages.NewPages(config)
60
61 res, err := idresolver.RedisResolver(config.Redis)
62 if err != nil {
63 log.Printf("failed to create redis resolver: %v", err)
64 res = idresolver.DefaultResolver()
65 }
66
67 oauth := oauth.NewOAuth(d, config)
68
69 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
70 if err != nil {
71 return nil, fmt.Errorf("failed to create posthog client: %w", err)
72 }
73
74 repoResolver := reporesolver.New(config, enforcer, res, d)
75
76 wrapper := db.DbWrapper{d}
77 jc, err := jetstream.NewJetstreamClient(
78 config.Jetstream.Endpoint,
79 "appview",
80 []string{
81 tangled.GraphFollowNSID,
82 tangled.FeedStarNSID,
83 tangled.PublicKeyNSID,
84 tangled.RepoArtifactNSID,
85 tangled.ActorProfileNSID,
86 },
87 nil,
88 slog.Default(),
89 wrapper,
90 false,
91 )
92 if err != nil {
93 return nil, fmt.Errorf("failed to create jetstream client: %w", err)
94 }
95 err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer))
96 if err != nil {
97 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
98 }
99
100 state := &State{
101 d,
102 oauth,
103 enforcer,
104 clock,
105 pgs,
106 res,
107 posthog,
108 jc,
109 config,
110 repoResolver,
111 }
112
113 return state, nil
114}
115
116func TID(c *syntax.TIDClock) string {
117 return c.Next().String()
118}
119
120func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
121 s.oauth.ClearSession(r, w)
122 w.Header().Set("HX-Redirect", "/login")
123 w.WriteHeader(http.StatusSeeOther)
124}
125
126func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
127 user := s.oauth.GetUser(r)
128
129 timeline, err := db.MakeTimeline(s.db)
130 if err != nil {
131 log.Println(err)
132 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
133 }
134
135 var didsToResolve []string
136 for _, ev := range timeline {
137 if ev.Repo != nil {
138 didsToResolve = append(didsToResolve, ev.Repo.Did)
139 if ev.Source != nil {
140 didsToResolve = append(didsToResolve, ev.Source.Did)
141 }
142 }
143 if ev.Follow != nil {
144 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
145 }
146 if ev.Star != nil {
147 didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
148 }
149 }
150
151 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
152 didHandleMap := make(map[string]string)
153 for _, identity := range resolvedIds {
154 if !identity.Handle.IsInvalidHandle() {
155 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
156 } else {
157 didHandleMap[identity.DID.String()] = identity.DID.String()
158 }
159 }
160
161 s.pages.Timeline(w, pages.TimelineParams{
162 LoggedInUser: user,
163 Timeline: timeline,
164 DidHandleMap: didHandleMap,
165 })
166
167 return
168}
169
170// requires auth
171func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
172 switch r.Method {
173 case http.MethodGet:
174 // list open registrations under this did
175
176 return
177 case http.MethodPost:
178 session, err := s.oauth.Store.Get(r, appview.SessionName)
179 if err != nil || session.IsNew {
180 log.Println("unauthorized attempt to generate registration key")
181 http.Error(w, "Forbidden", http.StatusUnauthorized)
182 return
183 }
184
185 did := session.Values[appview.SessionDid].(string)
186
187 // check if domain is valid url, and strip extra bits down to just host
188 domain := r.FormValue("domain")
189 if domain == "" {
190 http.Error(w, "Invalid form", http.StatusBadRequest)
191 return
192 }
193
194 key, err := db.GenerateRegistrationKey(s.db, domain, did)
195
196 if err != nil {
197 log.Println(err)
198 http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
199 return
200 }
201
202 w.Write([]byte(key))
203 }
204}
205
206func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
207 user := chi.URLParam(r, "user")
208 user = strings.TrimPrefix(user, "@")
209
210 if user == "" {
211 w.WriteHeader(http.StatusBadRequest)
212 return
213 }
214
215 id, err := s.idResolver.ResolveIdent(r.Context(), user)
216 if err != nil {
217 w.WriteHeader(http.StatusInternalServerError)
218 return
219 }
220
221 pubKeys, err := db.GetPublicKeys(s.db, id.DID.String())
222 if err != nil {
223 w.WriteHeader(http.StatusNotFound)
224 return
225 }
226
227 if len(pubKeys) == 0 {
228 w.WriteHeader(http.StatusNotFound)
229 return
230 }
231
232 for _, k := range pubKeys {
233 key := strings.TrimRight(k.Key, "\n")
234 w.Write([]byte(fmt.Sprintln(key)))
235 }
236}
237
238// create a signed request and check if a node responds to that
239func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
240 user := s.oauth.GetUser(r)
241
242 domain := chi.URLParam(r, "domain")
243 if domain == "" {
244 http.Error(w, "malformed url", http.StatusBadRequest)
245 return
246 }
247 log.Println("checking ", domain)
248
249 secret, err := db.GetRegistrationKey(s.db, domain)
250 if err != nil {
251 log.Printf("no key found for domain %s: %s\n", domain, err)
252 return
253 }
254
255 client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
256 if err != nil {
257 log.Println("failed to create client to ", domain)
258 }
259
260 resp, err := client.Init(user.Did)
261 if err != nil {
262 w.Write([]byte("no dice"))
263 log.Println("domain was unreachable after 5 seconds")
264 return
265 }
266
267 if resp.StatusCode == http.StatusConflict {
268 log.Println("status conflict", resp.StatusCode)
269 w.Write([]byte("already registered, sorry!"))
270 return
271 }
272
273 if resp.StatusCode != http.StatusNoContent {
274 log.Println("status nok", resp.StatusCode)
275 w.Write([]byte("no dice"))
276 return
277 }
278
279 // verify response mac
280 signature := resp.Header.Get("X-Signature")
281 signatureBytes, err := hex.DecodeString(signature)
282 if err != nil {
283 return
284 }
285
286 expectedMac := hmac.New(sha256.New, []byte(secret))
287 expectedMac.Write([]byte("ok"))
288
289 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
290 log.Printf("response body signature mismatch: %x\n", signatureBytes)
291 return
292 }
293
294 tx, err := s.db.BeginTx(r.Context(), nil)
295 if err != nil {
296 log.Println("failed to start tx", err)
297 http.Error(w, err.Error(), http.StatusInternalServerError)
298 return
299 }
300 defer func() {
301 tx.Rollback()
302 err = s.enforcer.E.LoadPolicy()
303 if err != nil {
304 log.Println("failed to rollback policies")
305 }
306 }()
307
308 // mark as registered
309 err = db.Register(tx, domain)
310 if err != nil {
311 log.Println("failed to register domain", err)
312 http.Error(w, err.Error(), http.StatusInternalServerError)
313 return
314 }
315
316 // set permissions for this did as owner
317 reg, err := db.RegistrationByDomain(tx, domain)
318 if err != nil {
319 log.Println("failed to register domain", err)
320 http.Error(w, err.Error(), http.StatusInternalServerError)
321 return
322 }
323
324 // add basic acls for this domain
325 err = s.enforcer.AddDomain(domain)
326 if err != nil {
327 log.Println("failed to setup owner of domain", err)
328 http.Error(w, err.Error(), http.StatusInternalServerError)
329 return
330 }
331
332 // add this did as owner of this domain
333 err = s.enforcer.AddOwner(domain, reg.ByDid)
334 if err != nil {
335 log.Println("failed to setup owner of domain", err)
336 http.Error(w, err.Error(), http.StatusInternalServerError)
337 return
338 }
339
340 err = tx.Commit()
341 if err != nil {
342 log.Println("failed to commit changes", err)
343 http.Error(w, err.Error(), http.StatusInternalServerError)
344 return
345 }
346
347 err = s.enforcer.E.SavePolicy()
348 if err != nil {
349 log.Println("failed to update ACLs", err)
350 http.Error(w, err.Error(), http.StatusInternalServerError)
351 return
352 }
353
354 w.Write([]byte("check success"))
355}
356
357func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
358 domain := chi.URLParam(r, "domain")
359 if domain == "" {
360 http.Error(w, "malformed url", http.StatusBadRequest)
361 return
362 }
363
364 user := s.oauth.GetUser(r)
365 reg, err := db.RegistrationByDomain(s.db, domain)
366 if err != nil {
367 w.Write([]byte("failed to pull up registration info"))
368 return
369 }
370
371 var members []string
372 if reg.Registered != nil {
373 members, err = s.enforcer.GetUserByRole("server:member", domain)
374 if err != nil {
375 w.Write([]byte("failed to fetch member list"))
376 return
377 }
378 }
379
380 var didsToResolve []string
381 for _, m := range members {
382 didsToResolve = append(didsToResolve, m)
383 }
384 didsToResolve = append(didsToResolve, reg.ByDid)
385 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
386 didHandleMap := make(map[string]string)
387 for _, identity := range resolvedIds {
388 if !identity.Handle.IsInvalidHandle() {
389 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
390 } else {
391 didHandleMap[identity.DID.String()] = identity.DID.String()
392 }
393 }
394
395 ok, err := s.enforcer.IsServerOwner(user.Did, domain)
396 isOwner := err == nil && ok
397
398 p := pages.KnotParams{
399 LoggedInUser: user,
400 DidHandleMap: didHandleMap,
401 Registration: reg,
402 Members: members,
403 IsOwner: isOwner,
404 }
405
406 s.pages.Knot(w, p)
407}
408
409// get knots registered by this user
410func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
411 // for now, this is just pubkeys
412 user := s.oauth.GetUser(r)
413 registrations, err := db.RegistrationsByDid(s.db, user.Did)
414 if err != nil {
415 log.Println(err)
416 }
417
418 s.pages.Knots(w, pages.KnotsParams{
419 LoggedInUser: user,
420 Registrations: registrations,
421 })
422}
423
424// list members of domain, requires auth and requires owner status
425func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
426 domain := chi.URLParam(r, "domain")
427 if domain == "" {
428 http.Error(w, "malformed url", http.StatusBadRequest)
429 return
430 }
431
432 // list all members for this domain
433 memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
434 if err != nil {
435 w.Write([]byte("failed to fetch member list"))
436 return
437 }
438
439 w.Write([]byte(strings.Join(memberDids, "\n")))
440 return
441}
442
443// add member to domain, requires auth and requires invite access
444func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
445 domain := chi.URLParam(r, "domain")
446 if domain == "" {
447 http.Error(w, "malformed url", http.StatusBadRequest)
448 return
449 }
450
451 subjectIdentifier := r.FormValue("subject")
452 if subjectIdentifier == "" {
453 http.Error(w, "malformed form", http.StatusBadRequest)
454 return
455 }
456
457 subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier)
458 if err != nil {
459 w.Write([]byte("failed to resolve member did to a handle"))
460 return
461 }
462 log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
463
464 // announce this relation into the firehose, store into owners' pds
465 client, err := s.oauth.AuthorizedClient(r)
466 if err != nil {
467 http.Error(w, "failed to authorize client", http.StatusInternalServerError)
468 return
469 }
470 currentUser := s.oauth.GetUser(r)
471 createdAt := time.Now().Format(time.RFC3339)
472 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
473 Collection: tangled.KnotMemberNSID,
474 Repo: currentUser.Did,
475 Rkey: appview.TID(),
476 Record: &lexutil.LexiconTypeDecoder{
477 Val: &tangled.KnotMember{
478 Subject: subjectIdentity.DID.String(),
479 Domain: domain,
480 CreatedAt: createdAt,
481 }},
482 })
483
484 // invalid record
485 if err != nil {
486 log.Printf("failed to create record: %s", err)
487 return
488 }
489 log.Println("created atproto record: ", resp.Uri)
490
491 secret, err := db.GetRegistrationKey(s.db, domain)
492 if err != nil {
493 log.Printf("no key found for domain %s: %s\n", domain, err)
494 return
495 }
496
497 ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
498 if err != nil {
499 log.Println("failed to create client to ", domain)
500 return
501 }
502
503 ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
504 if err != nil {
505 log.Printf("failed to make request to %s: %s", domain, err)
506 return
507 }
508
509 if ksResp.StatusCode != http.StatusNoContent {
510 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
511 return
512 }
513
514 err = s.enforcer.AddMember(domain, subjectIdentity.DID.String())
515 if err != nil {
516 w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
517 return
518 }
519
520 w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String())))
521}
522
523func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
524}
525
526func validateRepoName(name string) error {
527 // check for path traversal attempts
528 if name == "." || name == ".." ||
529 strings.Contains(name, "/") || strings.Contains(name, "\\") {
530 return fmt.Errorf("Repository name contains invalid path characters")
531 }
532
533 // check for sequences that could be used for traversal when normalized
534 if strings.Contains(name, "./") || strings.Contains(name, "../") ||
535 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
536 return fmt.Errorf("Repository name contains invalid path sequence")
537 }
538
539 // then continue with character validation
540 for _, char := range name {
541 if !((char >= 'a' && char <= 'z') ||
542 (char >= 'A' && char <= 'Z') ||
543 (char >= '0' && char <= '9') ||
544 char == '-' || char == '_' || char == '.') {
545 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
546 }
547 }
548
549 // additional check to prevent multiple sequential dots
550 if strings.Contains(name, "..") {
551 return fmt.Errorf("Repository name cannot contain sequential dots")
552 }
553
554 // if all checks pass
555 return nil
556}
557
558func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
559 switch r.Method {
560 case http.MethodGet:
561 user := s.oauth.GetUser(r)
562 knots, err := s.enforcer.GetDomainsForUser(user.Did)
563 if err != nil {
564 s.pages.Notice(w, "repo", "Invalid user account.")
565 return
566 }
567
568 s.pages.NewRepo(w, pages.NewRepoParams{
569 LoggedInUser: user,
570 Knots: knots,
571 })
572
573 case http.MethodPost:
574 user := s.oauth.GetUser(r)
575
576 domain := r.FormValue("domain")
577 if domain == "" {
578 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
579 return
580 }
581
582 repoName := r.FormValue("name")
583 if repoName == "" {
584 s.pages.Notice(w, "repo", "Repository name cannot be empty.")
585 return
586 }
587
588 if err := validateRepoName(repoName); err != nil {
589 s.pages.Notice(w, "repo", err.Error())
590 return
591 }
592
593 defaultBranch := r.FormValue("branch")
594 if defaultBranch == "" {
595 defaultBranch = "main"
596 }
597
598 description := r.FormValue("description")
599
600 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
601 if err != nil || !ok {
602 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
603 return
604 }
605
606 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
607 if err == nil && existingRepo != nil {
608 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
609 return
610 }
611
612 secret, err := db.GetRegistrationKey(s.db, domain)
613 if err != nil {
614 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
615 return
616 }
617
618 client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
619 if err != nil {
620 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
621 return
622 }
623
624 rkey := appview.TID()
625 repo := &db.Repo{
626 Did: user.Did,
627 Name: repoName,
628 Knot: domain,
629 Rkey: rkey,
630 Description: description,
631 }
632
633 xrpcClient, err := s.oauth.AuthorizedClient(r)
634 if err != nil {
635 s.pages.Notice(w, "repo", "Failed to write record to PDS.")
636 return
637 }
638
639 createdAt := time.Now().Format(time.RFC3339)
640 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
641 Collection: tangled.RepoNSID,
642 Repo: user.Did,
643 Rkey: rkey,
644 Record: &lexutil.LexiconTypeDecoder{
645 Val: &tangled.Repo{
646 Knot: repo.Knot,
647 Name: repoName,
648 CreatedAt: createdAt,
649 Owner: user.Did,
650 }},
651 })
652 if err != nil {
653 log.Printf("failed to create record: %s", err)
654 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
655 return
656 }
657 log.Println("created repo record: ", atresp.Uri)
658
659 tx, err := s.db.BeginTx(r.Context(), nil)
660 if err != nil {
661 log.Println(err)
662 s.pages.Notice(w, "repo", "Failed to save repository information.")
663 return
664 }
665 defer func() {
666 tx.Rollback()
667 err = s.enforcer.E.LoadPolicy()
668 if err != nil {
669 log.Println("failed to rollback policies")
670 }
671 }()
672
673 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
674 if err != nil {
675 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
676 return
677 }
678
679 switch resp.StatusCode {
680 case http.StatusConflict:
681 s.pages.Notice(w, "repo", "A repository with that name already exists.")
682 return
683 case http.StatusInternalServerError:
684 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
685 case http.StatusNoContent:
686 // continue
687 }
688
689 repo.AtUri = atresp.Uri
690 err = db.AddRepo(tx, repo)
691 if err != nil {
692 log.Println(err)
693 s.pages.Notice(w, "repo", "Failed to save repository information.")
694 return
695 }
696
697 // acls
698 p, _ := securejoin.SecureJoin(user.Did, repoName)
699 err = s.enforcer.AddRepo(user.Did, domain, p)
700 if err != nil {
701 log.Println(err)
702 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
703 return
704 }
705
706 err = tx.Commit()
707 if err != nil {
708 log.Println("failed to commit changes", err)
709 http.Error(w, err.Error(), http.StatusInternalServerError)
710 return
711 }
712
713 err = s.enforcer.E.SavePolicy()
714 if err != nil {
715 log.Println("failed to update ACLs", err)
716 http.Error(w, err.Error(), http.StatusInternalServerError)
717 return
718 }
719
720 if !s.config.Core.Dev {
721 err = s.posthog.Enqueue(posthog.Capture{
722 DistinctId: user.Did,
723 Event: "new_repo",
724 Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri},
725 })
726 if err != nil {
727 log.Println("failed to enqueue posthog event:", err)
728 }
729 }
730
731 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
732 return
733 }
734}