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