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