package state import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "log/slog" "net/http" "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/jetstream/pkg/models" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" tangled "github.com/sotangled/tangled/api/tangled" "github.com/sotangled/tangled/appview" "github.com/sotangled/tangled/appview/auth" "github.com/sotangled/tangled/appview/db" "github.com/sotangled/tangled/appview/pages" "github.com/sotangled/tangled/jetstream" "github.com/sotangled/tangled/rbac" ) type State struct { db *db.DB auth *auth.Auth enforcer *rbac.Enforcer tidClock *syntax.TIDClock pages *pages.Pages resolver *appview.Resolver jc *jetstream.JetstreamClient } func Make() (*State, error) { db, err := db.Make(appview.SqliteDbPath) if err != nil { return nil, err } auth, err := auth.Make() if err != nil { return nil, err } enforcer, err := rbac.NewEnforcer(appview.SqliteDbPath) if err != nil { return nil, err } clock := syntax.NewTIDClock(0) pgs := pages.NewPages() resolver := appview.NewResolver() jc, err := jetstream.NewJetstreamClient("appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), db, false) if err != nil { return nil, fmt.Errorf("failed to create jetstream client: %w", err) } err = jc.StartJetstream(context.Background(), func(ctx context.Context, e *models.Event) error { if e.Kind != models.EventKindCommit { return nil } did := e.Did raw := json.RawMessage(e.Commit.Record) switch e.Commit.Collection { case tangled.GraphFollowNSID: record := tangled.GraphFollow{} err := json.Unmarshal(raw, &record) if err != nil { log.Println("invalid record") return err } err = db.AddFollow(did, record.Subject, e.Commit.RKey) if err != nil { return fmt.Errorf("failed to add follow to db: %w", err) } return db.UpdateLastTimeUs(e.TimeUS) } return nil }) if err != nil { return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) } state := &State{ db, auth, enforcer, clock, pgs, resolver, jc, } return state, nil } func (s *State) TID() string { return s.tidClock.Next().String() } func (s *State) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() switch r.Method { case http.MethodGet: err := s.pages.Login(w, pages.LoginParams{}) if err != nil { log.Printf("rendering login page: %s", err) } return case http.MethodPost: handle := strings.TrimPrefix(r.FormValue("handle"), "@") appPassword := r.FormValue("app_password") resolved, err := s.resolver.ResolveIdent(ctx, handle) if err != nil { log.Println("failed to resolve handle:", err) s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) return } atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword) if err != nil { s.pages.Notice(w, "login-msg", "Invalid handle or password.") return } sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession} err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint()) if err != nil { s.pages.Notice(w, "login-msg", "Failed to login, try again later.") return } log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) s.pages.HxRedirect(w, "/") return } } func (s *State) Logout(w http.ResponseWriter, r *http.Request) { s.auth.ClearSession(r, w) s.pages.HxRedirect(w, "/") } func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { user := s.auth.GetUser(r) timeline, err := s.db.MakeTimeline() if err != nil { log.Println(err) s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") } var didsToResolve []string for _, ev := range timeline { if ev.Repo != nil { didsToResolve = append(didsToResolve, ev.Repo.Did) } if ev.Follow != nil { didsToResolve = append(didsToResolve, ev.Follow.UserDid) didsToResolve = append(didsToResolve, ev.Follow.SubjectDid) } } resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) } else { didHandleMap[identity.DID.String()] = identity.DID.String() } } s.pages.Timeline(w, pages.TimelineParams{ LoggedInUser: user, Timeline: timeline, DidHandleMap: didHandleMap, }) return } // requires auth func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // list open registrations under this did return case http.MethodPost: session, err := s.auth.Store.Get(r, appview.SessionName) if err != nil || session.IsNew { log.Println("unauthorized attempt to generate registration key") http.Error(w, "Forbidden", http.StatusUnauthorized) return } did := session.Values[appview.SessionDid].(string) // check if domain is valid url, and strip extra bits down to just host domain := r.FormValue("domain") if domain == "" { http.Error(w, "Invalid form", http.StatusBadRequest) return } key, err := s.db.GenerateRegistrationKey(domain, did) if err != nil { log.Println(err) http.Error(w, "unable to register this domain", http.StatusNotAcceptable) return } w.Write([]byte(key)) } } func (s *State) Keys(w http.ResponseWriter, r *http.Request) { user := chi.URLParam(r, "user") user = strings.TrimPrefix(user, "@") if user == "" { w.WriteHeader(http.StatusBadRequest) return } id, err := s.resolver.ResolveIdent(r.Context(), user) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } pubKeys, err := s.db.GetPublicKeys(id.DID.String()) if err != nil { w.WriteHeader(http.StatusNotFound) return } if len(pubKeys) == 0 { w.WriteHeader(http.StatusNotFound) return } for _, k := range pubKeys { key := strings.TrimRight(k.Key, "\n") w.Write([]byte(fmt.Sprintln(key))) } } // create a signed request and check if a node responds to that func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { user := s.auth.GetUser(r) domain := chi.URLParam(r, "domain") if domain == "" { http.Error(w, "malformed url", http.StatusBadRequest) return } log.Println("checking ", domain) secret, err := s.db.GetRegistrationKey(domain) if err != nil { log.Printf("no key found for domain %s: %s\n", domain, err) return } client, err := NewSignedClient(domain, secret) if err != nil { log.Println("failed to create client to ", domain) } resp, err := client.Init(user.Did) if err != nil { w.Write([]byte("no dice")) log.Println("domain was unreachable after 5 seconds") return } if resp.StatusCode == http.StatusConflict { log.Println("status conflict", resp.StatusCode) w.Write([]byte("already registered, sorry!")) return } if resp.StatusCode != http.StatusNoContent { log.Println("status nok", resp.StatusCode) w.Write([]byte("no dice")) return } // verify response mac signature := resp.Header.Get("X-Signature") signatureBytes, err := hex.DecodeString(signature) if err != nil { return } expectedMac := hmac.New(sha256.New, []byte(secret)) expectedMac.Write([]byte("ok")) if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { log.Printf("response body signature mismatch: %x\n", signatureBytes) return } // mark as registered err = s.db.Register(domain) if err != nil { log.Println("failed to register domain", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // set permissions for this did as owner reg, err := s.db.RegistrationByDomain(domain) if err != nil { log.Println("failed to register domain", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // add basic acls for this domain err = s.enforcer.AddDomain(domain) if err != nil { log.Println("failed to setup owner of domain", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // add this did as owner of this domain err = s.enforcer.AddOwner(domain, reg.ByDid) if err != nil { log.Println("failed to setup owner of domain", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write([]byte("check success")) } func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { domain := chi.URLParam(r, "domain") if domain == "" { http.Error(w, "malformed url", http.StatusBadRequest) return } user := s.auth.GetUser(r) reg, err := s.db.RegistrationByDomain(domain) if err != nil { w.Write([]byte("failed to pull up registration info")) return } var members []string if reg.Registered != nil { members, err = s.enforcer.GetUserByRole("server:member", domain) if err != nil { w.Write([]byte("failed to fetch member list")) return } } ok, err := s.enforcer.IsServerOwner(user.Did, domain) isOwner := err == nil && ok p := pages.KnotParams{ LoggedInUser: user, Registration: reg, Members: members, IsOwner: isOwner, } s.pages.Knot(w, p) } // get knots registered by this user func (s *State) Knots(w http.ResponseWriter, r *http.Request) { // for now, this is just pubkeys user := s.auth.GetUser(r) registrations, err := s.db.RegistrationsByDid(user.Did) if err != nil { log.Println(err) } s.pages.Knots(w, pages.KnotsParams{ LoggedInUser: user, Registrations: registrations, }) } // list members of domain, requires auth and requires owner status func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { domain := chi.URLParam(r, "domain") if domain == "" { http.Error(w, "malformed url", http.StatusBadRequest) return } // list all members for this domain memberDids, err := s.enforcer.GetUserByRole("server:member", domain) if err != nil { w.Write([]byte("failed to fetch member list")) return } w.Write([]byte(strings.Join(memberDids, "\n"))) return } // add member to domain, requires auth and requires invite access func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { domain := chi.URLParam(r, "domain") if domain == "" { http.Error(w, "malformed url", http.StatusBadRequest) return } memberDid := r.FormValue("member") if memberDid == "" { http.Error(w, "malformed form", http.StatusBadRequest) return } memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid) if err != nil { w.Write([]byte("failed to resolve member did to a handle")) return } log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain) // announce this relation into the firehose, store into owners' pds client, _ := s.auth.AuthorizedClient(r) currentUser := s.auth.GetUser(r) addedAt := time.Now().Format(time.RFC3339) resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.KnotMemberNSID, Repo: currentUser.Did, Rkey: s.TID(), Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.KnotMember{ Member: memberIdent.DID.String(), Domain: domain, AddedAt: &addedAt, }}, }) // invalid record if err != nil { log.Printf("failed to create record: %s", err) return } log.Println("created atproto record: ", resp.Uri) secret, err := s.db.GetRegistrationKey(domain) if err != nil { log.Printf("no key found for domain %s: %s\n", domain, err) return } ksClient, err := NewSignedClient(domain, secret) if err != nil { log.Println("failed to create client to ", domain) return } ksResp, err := ksClient.AddMember(memberIdent.DID.String()) if err != nil { log.Printf("failed to make request to %s: %s", domain, err) return } if ksResp.StatusCode != http.StatusNoContent { w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) return } err = s.enforcer.AddMember(domain, memberIdent.DID.String()) if err != nil { w.Write([]byte(fmt.Sprint("failed to add member: ", err))) return } w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String()))) } func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { } func (s *State) AddRepo(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: user := s.auth.GetUser(r) knots, err := s.enforcer.GetDomainsForUser(user.Did) if err != nil { s.pages.Notice(w, "repo", "Invalid user account.") return } s.pages.NewRepo(w, pages.NewRepoParams{ LoggedInUser: user, Knots: knots, }) case http.MethodPost: user := s.auth.GetUser(r) domain := r.FormValue("domain") if domain == "" { s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") return } repoName := r.FormValue("name") if repoName == "" { s.pages.Notice(w, "repo", "Invalid repo name.") return } ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") if err != nil || !ok { s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") return } secret, err := s.db.GetRegistrationKey(domain) if err != nil { s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) return } client, err := NewSignedClient(domain, secret) if err != nil { s.pages.Notice(w, "repo", "Failed to connect to knot server.") return } resp, err := client.NewRepo(user.Did, repoName) if err != nil { s.pages.Notice(w, "repo", "Failed to create repository on knot server.") return } switch resp.StatusCode { case http.StatusConflict: s.pages.Notice(w, "repo", "A repository with that name already exists.") return case http.StatusInternalServerError: s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") case http.StatusNoContent: // continue } rkey := s.TID() repo := &db.Repo{ Did: user.Did, Name: repoName, Knot: domain, Rkey: rkey, } xrpcClient, _ := s.auth.AuthorizedClient(r) addedAt := time.Now().Format(time.RFC3339) atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.Repo{ Knot: repo.Knot, Name: repoName, AddedAt: &addedAt, Owner: user.Did, }}, }) if err != nil { log.Printf("failed to create record: %s", err) s.pages.Notice(w, "repo", "Failed to announce repository creation.") return } log.Println("created repo record: ", atresp.Uri) repo.AtUri = atresp.Uri err = s.db.AddRepo(repo) if err != nil { log.Println(err) s.pages.Notice(w, "repo", "Failed to save repository information.") return } // acls p, _ := securejoin.SecureJoin(user.Did, repoName) err = s.enforcer.AddRepo(user.Did, domain, p) if err != nil { log.Println(err) s.pages.Notice(w, "repo", "Failed to set up repository permissions.") return } s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) return } } func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { didOrHandle := chi.URLParam(r, "user") if didOrHandle == "" { http.Error(w, "Bad request", http.StatusBadRequest) return } ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle) if err != nil { log.Printf("resolving identity: %s", err) w.WriteHeader(http.StatusNotFound) return } repos, err := s.db.GetAllReposByDid(ident.DID.String()) if err != nil { log.Printf("getting repos for %s: %s", ident.DID.String(), err) } collaboratingRepos, err := s.db.CollaboratingIn(ident.DID.String()) if err != nil { log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) } followers, following, err := s.db.GetFollowerFollowing(ident.DID.String()) if err != nil { log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) } loggedInUser := s.auth.GetUser(r) followStatus := db.IsNotFollowing if loggedInUser != nil { followStatus = s.db.GetFollowStatus(loggedInUser.Did, ident.DID.String()) } s.pages.ProfilePage(w, pages.ProfilePageParams{ LoggedInUser: loggedInUser, UserDid: ident.DID.String(), UserHandle: ident.Handle.String(), Repos: repos, CollaboratingRepos: collaboratingRepos, ProfileStats: pages.ProfileStats{ Followers: followers, Following: following, }, FollowStatus: db.FollowStatus(followStatus), }) } func (s *State) Follow(w http.ResponseWriter, r *http.Request) { currentUser := s.auth.GetUser(r) subject := r.URL.Query().Get("subject") if subject == "" { log.Println("invalid form") return } subjectIdent, err := s.resolver.ResolveIdent(r.Context(), subject) if err != nil { log.Println("failed to follow, invalid did") } if currentUser.Did == subjectIdent.DID.String() { log.Println("cant follow or unfollow yourself") return } client, _ := s.auth.AuthorizedClient(r) switch r.Method { case http.MethodPost: createdAt := time.Now().Format(time.RFC3339) rkey := s.TID() resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: currentUser.Did, Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.GraphFollow{ Subject: subjectIdent.DID.String(), CreatedAt: createdAt, }}, }) if err != nil { log.Println("failed to create atproto record", err) return } err = s.db.AddFollow(currentUser.Did, subjectIdent.DID.String(), rkey) if err != nil { log.Println("failed to follow", err) return } log.Println("created atproto record: ", resp.Uri) w.Write([]byte(fmt.Sprintf(` `, subjectIdent.DID.String()))) return case http.MethodDelete: // find the record in the db follow, err := s.db.GetFollow(currentUser.Did, subjectIdent.DID.String()) if err != nil { log.Println("failed to get follow relationship") return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: currentUser.Did, Rkey: follow.RKey, }) if err != nil { log.Println("failed to unfollow") return } err = s.db.DeleteFollow(currentUser.Did, subjectIdent.DID.String()) if err != nil { log.Println("failed to delete follow from DB") // this is not an issue, the firehose event might have already done this } w.Write([]byte(fmt.Sprintf(` `, subjectIdent.DID.String()))) return } } func (s *State) Router() http.Handler { router := chi.NewRouter() router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { pat := chi.URLParam(r, "*") if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { s.UserRouter().ServeHTTP(w, r) } else { s.StandardRouter().ServeHTTP(w, r) } }) return router } func (s *State) UserRouter() http.Handler { r := chi.NewRouter() // strip @ from user r.Use(StripLeadingAt) r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { r.Get("/", s.ProfilePage) r.With(ResolveRepoKnot(s)).Route("/{repo}", func(r chi.Router) { r.Get("/", s.RepoIndex) r.Get("/log/{ref}", s.RepoLog) r.Route("/tree/{ref}", func(r chi.Router) { r.Get("/", s.RepoIndex) r.Get("/*", s.RepoTree) }) r.Get("/commit/{ref}", s.RepoCommit) r.Get("/branches", s.RepoBranches) r.Get("/tags", s.RepoTags) r.Get("/blob/{ref}/*", s.RepoBlob) r.Route("/issues", func(r chi.Router) { r.Get("/", s.RepoIssues) r.Get("/{issue}", s.RepoSingleIssue) r.Get("/new", s.NewIssue) r.Post("/new", s.NewIssue) r.Post("/{issue}/comment", s.IssueComment) r.Post("/{issue}/close", s.CloseIssue) r.Post("/{issue}/reopen", s.ReopenIssue) }) // These routes get proxied to the knot r.Get("/info/refs", s.InfoRefs) r.Post("/git-upload-pack", s.UploadPack) // settings routes, needs auth r.Group(func(r chi.Router) { r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { r.Get("/", s.RepoSettings) r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) }) }) }) }) r.NotFound(func(w http.ResponseWriter, r *http.Request) { s.pages.Error404(w) }) return r } func (s *State) StandardRouter() http.Handler { r := chi.NewRouter() r.Handle("/static/*", s.pages.Static()) r.Get("/", s.Timeline) r.Get("/logout", s.Logout) r.Get("/login", s.Login) r.Post("/login", s.Login) r.Route("/knots", func(r chi.Router) { r.Use(AuthMiddleware(s)) r.Get("/", s.Knots) r.Post("/key", s.RegistrationKey) r.Route("/{domain}", func(r chi.Router) { r.Post("/init", s.InitKnotServer) r.Get("/", s.KnotServerInfo) r.Route("/member", func(r chi.Router) { r.Use(RoleMiddleware(s, "server:owner")) r.Get("/", s.ListMembers) r.Put("/", s.AddMember) r.Delete("/", s.RemoveMember) }) }) }) r.Route("/repo", func(r chi.Router) { r.Route("/new", func(r chi.Router) { r.Get("/", s.AddRepo) r.Post("/", s.AddRepo) }) // r.Post("/import", s.ImportRepo) }) r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) { r.Post("/", s.Follow) r.Delete("/", s.Follow) }) r.Route("/settings", func(r chi.Router) { r.Use(AuthMiddleware(s)) r.Get("/", s.Settings) r.Put("/keys", s.SettingsKeys) }) r.Get("/keys/{user}", s.Keys) r.NotFound(func(w http.ResponseWriter, r *http.Request) { s.pages.Error404(w) }) return r }