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