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