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