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