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