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/oauth"
25 "tangled.sh/tangled.sh/core/appview/pages"
26 "tangled.sh/tangled.sh/core/appview/reporesolver"
27 "tangled.sh/tangled.sh/core/jetstream"
28 "tangled.sh/tangled.sh/core/knotclient"
29 "tangled.sh/tangled.sh/core/rbac"
30 "tangled.sh/tangled.sh/core/resolver"
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 resolver *resolver.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 := resolver.RedisResolver(config.Redis.ToURL())
62 if err != nil {
63 log.Printf("failed to create redis resolver: %v", err)
64 res = resolver.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 tangled.KnotNSID,
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, config.Core.Dev))
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.resolver.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, appview.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[appview.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.resolver.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.resolver.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 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.resolver.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}