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