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 "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()
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},
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(), jetstreamIngester(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 (s *State) TID() string {
95 return s.tidClock.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 }
194 if ev.Follow != nil {
195 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
196 }
197 if ev.Star != nil {
198 didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
199 }
200 }
201
202 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
203 didHandleMap := make(map[string]string)
204 for _, identity := range resolvedIds {
205 if !identity.Handle.IsInvalidHandle() {
206 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
207 } else {
208 didHandleMap[identity.DID.String()] = identity.DID.String()
209 }
210 }
211
212 s.pages.Timeline(w, pages.TimelineParams{
213 LoggedInUser: user,
214 Timeline: timeline,
215 DidHandleMap: didHandleMap,
216 })
217
218 return
219}
220
221// requires auth
222func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
223 switch r.Method {
224 case http.MethodGet:
225 // list open registrations under this did
226
227 return
228 case http.MethodPost:
229 session, err := s.auth.Store.Get(r, appview.SessionName)
230 if err != nil || session.IsNew {
231 log.Println("unauthorized attempt to generate registration key")
232 http.Error(w, "Forbidden", http.StatusUnauthorized)
233 return
234 }
235
236 did := session.Values[appview.SessionDid].(string)
237
238 // check if domain is valid url, and strip extra bits down to just host
239 domain := r.FormValue("domain")
240 if domain == "" {
241 http.Error(w, "Invalid form", http.StatusBadRequest)
242 return
243 }
244
245 key, err := db.GenerateRegistrationKey(s.db, domain, did)
246
247 if err != nil {
248 log.Println(err)
249 http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
250 return
251 }
252
253 w.Write([]byte(key))
254 }
255}
256
257func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
258 user := chi.URLParam(r, "user")
259 user = strings.TrimPrefix(user, "@")
260
261 if user == "" {
262 w.WriteHeader(http.StatusBadRequest)
263 return
264 }
265
266 id, err := s.resolver.ResolveIdent(r.Context(), user)
267 if err != nil {
268 w.WriteHeader(http.StatusInternalServerError)
269 return
270 }
271
272 pubKeys, err := db.GetPublicKeys(s.db, id.DID.String())
273 if err != nil {
274 w.WriteHeader(http.StatusNotFound)
275 return
276 }
277
278 if len(pubKeys) == 0 {
279 w.WriteHeader(http.StatusNotFound)
280 return
281 }
282
283 for _, k := range pubKeys {
284 key := strings.TrimRight(k.Key, "\n")
285 w.Write([]byte(fmt.Sprintln(key)))
286 }
287}
288
289// create a signed request and check if a node responds to that
290func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
291 user := s.auth.GetUser(r)
292
293 domain := chi.URLParam(r, "domain")
294 if domain == "" {
295 http.Error(w, "malformed url", http.StatusBadRequest)
296 return
297 }
298 log.Println("checking ", domain)
299
300 secret, err := db.GetRegistrationKey(s.db, domain)
301 if err != nil {
302 log.Printf("no key found for domain %s: %s\n", domain, err)
303 return
304 }
305
306 client, err := NewSignedClient(domain, secret, s.config.Dev)
307 if err != nil {
308 log.Println("failed to create client to ", domain)
309 }
310
311 resp, err := client.Init(user.Did)
312 if err != nil {
313 w.Write([]byte("no dice"))
314 log.Println("domain was unreachable after 5 seconds")
315 return
316 }
317
318 if resp.StatusCode == http.StatusConflict {
319 log.Println("status conflict", resp.StatusCode)
320 w.Write([]byte("already registered, sorry!"))
321 return
322 }
323
324 if resp.StatusCode != http.StatusNoContent {
325 log.Println("status nok", resp.StatusCode)
326 w.Write([]byte("no dice"))
327 return
328 }
329
330 // verify response mac
331 signature := resp.Header.Get("X-Signature")
332 signatureBytes, err := hex.DecodeString(signature)
333 if err != nil {
334 return
335 }
336
337 expectedMac := hmac.New(sha256.New, []byte(secret))
338 expectedMac.Write([]byte("ok"))
339
340 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
341 log.Printf("response body signature mismatch: %x\n", signatureBytes)
342 return
343 }
344
345 tx, err := s.db.BeginTx(r.Context(), nil)
346 if err != nil {
347 log.Println("failed to start tx", err)
348 http.Error(w, err.Error(), http.StatusInternalServerError)
349 return
350 }
351 defer func() {
352 tx.Rollback()
353 err = s.enforcer.E.LoadPolicy()
354 if err != nil {
355 log.Println("failed to rollback policies")
356 }
357 }()
358
359 // mark as registered
360 err = db.Register(tx, domain)
361 if err != nil {
362 log.Println("failed to register domain", err)
363 http.Error(w, err.Error(), http.StatusInternalServerError)
364 return
365 }
366
367 // set permissions for this did as owner
368 reg, err := db.RegistrationByDomain(tx, domain)
369 if err != nil {
370 log.Println("failed to register domain", err)
371 http.Error(w, err.Error(), http.StatusInternalServerError)
372 return
373 }
374
375 // add basic acls for this domain
376 err = s.enforcer.AddDomain(domain)
377 if err != nil {
378 log.Println("failed to setup owner of domain", err)
379 http.Error(w, err.Error(), http.StatusInternalServerError)
380 return
381 }
382
383 // add this did as owner of this domain
384 err = s.enforcer.AddOwner(domain, reg.ByDid)
385 if err != nil {
386 log.Println("failed to setup owner of domain", err)
387 http.Error(w, err.Error(), http.StatusInternalServerError)
388 return
389 }
390
391 err = tx.Commit()
392 if err != nil {
393 log.Println("failed to commit changes", err)
394 http.Error(w, err.Error(), http.StatusInternalServerError)
395 return
396 }
397
398 err = s.enforcer.E.SavePolicy()
399 if err != nil {
400 log.Println("failed to update ACLs", err)
401 http.Error(w, err.Error(), http.StatusInternalServerError)
402 return
403 }
404
405 w.Write([]byte("check success"))
406}
407
408func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
409 domain := chi.URLParam(r, "domain")
410 if domain == "" {
411 http.Error(w, "malformed url", http.StatusBadRequest)
412 return
413 }
414
415 user := s.auth.GetUser(r)
416 reg, err := db.RegistrationByDomain(s.db, domain)
417 if err != nil {
418 w.Write([]byte("failed to pull up registration info"))
419 return
420 }
421
422 var members []string
423 if reg.Registered != nil {
424 members, err = s.enforcer.GetUserByRole("server:member", domain)
425 if err != nil {
426 w.Write([]byte("failed to fetch member list"))
427 return
428 }
429 }
430
431 var didsToResolve []string
432 for _, m := range members {
433 didsToResolve = append(didsToResolve, m)
434 }
435 didsToResolve = append(didsToResolve, reg.ByDid)
436 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
437 didHandleMap := make(map[string]string)
438 for _, identity := range resolvedIds {
439 if !identity.Handle.IsInvalidHandle() {
440 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
441 } else {
442 didHandleMap[identity.DID.String()] = identity.DID.String()
443 }
444 }
445
446 ok, err := s.enforcer.IsServerOwner(user.Did, domain)
447 isOwner := err == nil && ok
448
449 p := pages.KnotParams{
450 LoggedInUser: user,
451 DidHandleMap: didHandleMap,
452 Registration: reg,
453 Members: members,
454 IsOwner: isOwner,
455 }
456
457 s.pages.Knot(w, p)
458}
459
460// get knots registered by this user
461func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
462 // for now, this is just pubkeys
463 user := s.auth.GetUser(r)
464 registrations, err := db.RegistrationsByDid(s.db, user.Did)
465 if err != nil {
466 log.Println(err)
467 }
468
469 s.pages.Knots(w, pages.KnotsParams{
470 LoggedInUser: user,
471 Registrations: registrations,
472 })
473}
474
475// list members of domain, requires auth and requires owner status
476func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
477 domain := chi.URLParam(r, "domain")
478 if domain == "" {
479 http.Error(w, "malformed url", http.StatusBadRequest)
480 return
481 }
482
483 // list all members for this domain
484 memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
485 if err != nil {
486 w.Write([]byte("failed to fetch member list"))
487 return
488 }
489
490 w.Write([]byte(strings.Join(memberDids, "\n")))
491 return
492}
493
494// add member to domain, requires auth and requires invite access
495func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
496 domain := chi.URLParam(r, "domain")
497 if domain == "" {
498 http.Error(w, "malformed url", http.StatusBadRequest)
499 return
500 }
501
502 memberDid := r.FormValue("member")
503 if memberDid == "" {
504 http.Error(w, "malformed form", http.StatusBadRequest)
505 return
506 }
507
508 memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid)
509 if err != nil {
510 w.Write([]byte("failed to resolve member did to a handle"))
511 return
512 }
513 log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain)
514
515 // announce this relation into the firehose, store into owners' pds
516 client, _ := s.auth.AuthorizedClient(r)
517 currentUser := s.auth.GetUser(r)
518 addedAt := time.Now().Format(time.RFC3339)
519 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
520 Collection: tangled.KnotMemberNSID,
521 Repo: currentUser.Did,
522 Rkey: s.TID(),
523 Record: &lexutil.LexiconTypeDecoder{
524 Val: &tangled.KnotMember{
525 Member: memberIdent.DID.String(),
526 Domain: domain,
527 AddedAt: &addedAt,
528 }},
529 })
530
531 // invalid record
532 if err != nil {
533 log.Printf("failed to create record: %s", err)
534 return
535 }
536 log.Println("created atproto record: ", resp.Uri)
537
538 secret, err := db.GetRegistrationKey(s.db, domain)
539 if err != nil {
540 log.Printf("no key found for domain %s: %s\n", domain, err)
541 return
542 }
543
544 ksClient, err := NewSignedClient(domain, secret, s.config.Dev)
545 if err != nil {
546 log.Println("failed to create client to ", domain)
547 return
548 }
549
550 ksResp, err := ksClient.AddMember(memberIdent.DID.String())
551 if err != nil {
552 log.Printf("failed to make request to %s: %s", domain, err)
553 return
554 }
555
556 if ksResp.StatusCode != http.StatusNoContent {
557 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
558 return
559 }
560
561 err = s.enforcer.AddMember(domain, memberIdent.DID.String())
562 if err != nil {
563 w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
564 return
565 }
566
567 w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String())))
568}
569
570func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
571}
572
573func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
574 switch r.Method {
575 case http.MethodGet:
576 user := s.auth.GetUser(r)
577 knots, err := s.enforcer.GetDomainsForUser(user.Did)
578 if err != nil {
579 s.pages.Notice(w, "repo", "Invalid user account.")
580 return
581 }
582
583 s.pages.NewRepo(w, pages.NewRepoParams{
584 LoggedInUser: user,
585 Knots: knots,
586 })
587
588 case http.MethodPost:
589 user := s.auth.GetUser(r)
590
591 domain := r.FormValue("domain")
592 if domain == "" {
593 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
594 return
595 }
596
597 repoName := r.FormValue("name")
598 if repoName == "" {
599 s.pages.Notice(w, "repo", "Repository name cannot be empty.")
600 return
601 }
602
603 // Check for valid repository name (GitHub-like rules)
604 // No spaces, only alphanumeric characters, dashes, and underscores
605 for _, char := range repoName {
606 if !((char >= 'a' && char <= 'z') ||
607 (char >= 'A' && char <= 'Z') ||
608 (char >= '0' && char <= '9') ||
609 char == '-' || char == '_' || char == '.') {
610 s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.")
611 return
612 }
613 }
614
615 defaultBranch := r.FormValue("branch")
616 if defaultBranch == "" {
617 defaultBranch = "main"
618 }
619
620 description := r.FormValue("description")
621
622 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
623 if err != nil || !ok {
624 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
625 return
626 }
627
628 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
629 if err == nil && existingRepo != nil {
630 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
631 return
632 }
633
634 secret, err := db.GetRegistrationKey(s.db, domain)
635 if err != nil {
636 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
637 return
638 }
639
640 client, err := NewSignedClient(domain, secret, s.config.Dev)
641 if err != nil {
642 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
643 return
644 }
645
646 rkey := s.TID()
647 repo := &db.Repo{
648 Did: user.Did,
649 Name: repoName,
650 Knot: domain,
651 Rkey: rkey,
652 Description: description,
653 }
654
655 xrpcClient, _ := s.auth.AuthorizedClient(r)
656
657 addedAt := time.Now().Format(time.RFC3339)
658 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
659 Collection: tangled.RepoNSID,
660 Repo: user.Did,
661 Rkey: rkey,
662 Record: &lexutil.LexiconTypeDecoder{
663 Val: &tangled.Repo{
664 Knot: repo.Knot,
665 Name: repoName,
666 AddedAt: &addedAt,
667 Owner: user.Did,
668 }},
669 })
670 if err != nil {
671 log.Printf("failed to create record: %s", err)
672 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
673 return
674 }
675 log.Println("created repo record: ", atresp.Uri)
676
677 tx, err := s.db.BeginTx(r.Context(), nil)
678 if err != nil {
679 log.Println(err)
680 s.pages.Notice(w, "repo", "Failed to save repository information.")
681 return
682 }
683 defer func() {
684 tx.Rollback()
685 err = s.enforcer.E.LoadPolicy()
686 if err != nil {
687 log.Println("failed to rollback policies")
688 }
689 }()
690
691 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
692 if err != nil {
693 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
694 return
695 }
696
697 switch resp.StatusCode {
698 case http.StatusConflict:
699 s.pages.Notice(w, "repo", "A repository with that name already exists.")
700 return
701 case http.StatusInternalServerError:
702 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
703 case http.StatusNoContent:
704 // continue
705 }
706
707 repo.AtUri = atresp.Uri
708 err = db.AddRepo(tx, repo)
709 if err != nil {
710 log.Println(err)
711 s.pages.Notice(w, "repo", "Failed to save repository information.")
712 return
713 }
714
715 // acls
716 p, _ := securejoin.SecureJoin(user.Did, repoName)
717 err = s.enforcer.AddRepo(user.Did, domain, p)
718 if err != nil {
719 log.Println(err)
720 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
721 return
722 }
723
724 err = tx.Commit()
725 if err != nil {
726 log.Println("failed to commit changes", err)
727 http.Error(w, err.Error(), http.StatusInternalServerError)
728 return
729 }
730
731 err = s.enforcer.E.SavePolicy()
732 if err != nil {
733 log.Println("failed to update ACLs", err)
734 http.Error(w, err.Error(), http.StatusInternalServerError)
735 return
736 }
737
738 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
739 return
740 }
741}
742
743func GetAvatarUri(handle string) (string, error) {
744 return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil
745}