forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
fork
Configure Feed
Select the types of activity you want to include in your feed.
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(config.Dev)
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},
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 (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
577 switch r.Method {
578 case http.MethodGet:
579 user := s.auth.GetUser(r)
580 knots, err := s.enforcer.GetDomainsForUser(user.Did)
581 if err != nil {
582 s.pages.Notice(w, "repo", "Invalid user account.")
583 return
584 }
585
586 s.pages.NewRepo(w, pages.NewRepoParams{
587 LoggedInUser: user,
588 Knots: knots,
589 })
590
591 case http.MethodPost:
592 user := s.auth.GetUser(r)
593
594 domain := r.FormValue("domain")
595 if domain == "" {
596 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
597 return
598 }
599
600 repoName := r.FormValue("name")
601 if repoName == "" {
602 s.pages.Notice(w, "repo", "Repository name cannot be empty.")
603 return
604 }
605
606 // Check for valid repository name (GitHub-like rules)
607 // No spaces, only alphanumeric characters, dashes, and underscores
608 for _, char := range repoName {
609 if !((char >= 'a' && char <= 'z') ||
610 (char >= 'A' && char <= 'Z') ||
611 (char >= '0' && char <= '9') ||
612 char == '-' || char == '_' || char == '.') {
613 s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.")
614 return
615 }
616 }
617
618 defaultBranch := r.FormValue("branch")
619 if defaultBranch == "" {
620 defaultBranch = "main"
621 }
622
623 description := r.FormValue("description")
624
625 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
626 if err != nil || !ok {
627 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
628 return
629 }
630
631 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
632 if err == nil && existingRepo != nil {
633 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
634 return
635 }
636
637 secret, err := db.GetRegistrationKey(s.db, domain)
638 if err != nil {
639 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
640 return
641 }
642
643 client, err := NewSignedClient(domain, secret, s.config.Dev)
644 if err != nil {
645 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
646 return
647 }
648
649 rkey := appview.TID()
650 repo := &db.Repo{
651 Did: user.Did,
652 Name: repoName,
653 Knot: domain,
654 Rkey: rkey,
655 Description: description,
656 }
657
658 xrpcClient, _ := s.auth.AuthorizedClient(r)
659
660 createdAt := time.Now().Format(time.RFC3339)
661 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
662 Collection: tangled.RepoNSID,
663 Repo: user.Did,
664 Rkey: rkey,
665 Record: &lexutil.LexiconTypeDecoder{
666 Val: &tangled.Repo{
667 Knot: repo.Knot,
668 Name: repoName,
669 CreatedAt: createdAt,
670 Owner: user.Did,
671 }},
672 })
673 if err != nil {
674 log.Printf("failed to create record: %s", err)
675 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
676 return
677 }
678 log.Println("created repo record: ", atresp.Uri)
679
680 tx, err := s.db.BeginTx(r.Context(), nil)
681 if err != nil {
682 log.Println(err)
683 s.pages.Notice(w, "repo", "Failed to save repository information.")
684 return
685 }
686 defer func() {
687 tx.Rollback()
688 err = s.enforcer.E.LoadPolicy()
689 if err != nil {
690 log.Println("failed to rollback policies")
691 }
692 }()
693
694 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
695 if err != nil {
696 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
697 return
698 }
699
700 switch resp.StatusCode {
701 case http.StatusConflict:
702 s.pages.Notice(w, "repo", "A repository with that name already exists.")
703 return
704 case http.StatusInternalServerError:
705 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
706 case http.StatusNoContent:
707 // continue
708 }
709
710 repo.AtUri = atresp.Uri
711 err = db.AddRepo(tx, repo)
712 if err != nil {
713 log.Println(err)
714 s.pages.Notice(w, "repo", "Failed to save repository information.")
715 return
716 }
717
718 // acls
719 p, _ := securejoin.SecureJoin(user.Did, repoName)
720 err = s.enforcer.AddRepo(user.Did, domain, p)
721 if err != nil {
722 log.Println(err)
723 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
724 return
725 }
726
727 err = tx.Commit()
728 if err != nil {
729 log.Println("failed to commit changes", err)
730 http.Error(w, err.Error(), http.StatusInternalServerError)
731 return
732 }
733
734 err = s.enforcer.E.SavePolicy()
735 if err != nil {
736 log.Println("failed to update ACLs", err)
737 http.Error(w, err.Error(), http.StatusInternalServerError)
738 return
739 }
740
741 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
742 return
743 }
744}
745
746func GetAvatarUri(handle string) (string, error) {
747 return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil
748}