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