diff --git a/appview/config/config.go b/appview/config/config.go index 73e29d0b..88194e2b 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -23,15 +23,6 @@ type CoreConfig struct { Dev bool `env:"DEV, default=false"` DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` - // gates the org.tangled.* xrpc router (/xrpc). off by default; the svelte - // frontend is the only consumer and isn't shipped yet. - XrpcEnabled bool `env:"XRPC_ENABLED, default=false"` - - // origin allowed to call the xrpc endpoints from the browser (the svelte - // frontend). empty allows any origin, which is safe here since xrpc uses - // bearer service-auth tokens rather than cookies. - XrpcCorsOrigin string `env:"XRPC_CORS_ORIGIN"` - // temporarily, to add users to default knot and spindle AppPassword string `env:"APP_PASSWORD"` diff --git a/appview/state/router.go b/appview/state/router.go index d86f95d2..08745d67 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -18,7 +18,6 @@ import ( "tangled.org/core/appview/middleware" "tangled.org/core/appview/migration" "tangled.org/core/appview/notifications" - whnotify "tangled.org/core/appview/notify/webhook" "tangled.org/core/appview/pipelines" "tangled.org/core/appview/pulls" "tangled.org/core/appview/repo" @@ -28,10 +27,8 @@ import ( "tangled.org/core/appview/state/userutil" avstrings "tangled.org/core/appview/strings" avtimeline "tangled.org/core/appview/timeline" - avxrpc "tangled.org/core/appview/xrpc" "tangled.org/core/blog" "tangled.org/core/log" - "tangled.org/core/xrpc/serviceauth" ) func (s *State) Router() http.Handler { @@ -300,9 +297,6 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/focus", s.FocusRouter(mw)) r.Mount("/signup", s.SignupRouter()) - if s.config.Core.XrpcEnabled { - r.Mount("/xrpc", s.XrpcRouter()) - } r.Mount("/", s.oauth.Router()) r.Get("/terms", s.TermsOfService) @@ -486,24 +480,3 @@ func (s *State) SignupRouter() http.Handler { sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) return sig.Router() } - -// XrpcRouter serves the org.tangled.* methods owned by the go service; callers -// authenticate with atproto service auth, audience did:web: -func (s *State) XrpcRouter() http.Handler { - audience := serviceauth.DidWeb(s.config.Core.AppviewHost).String() - sa := serviceauth.NewServiceAuth(s.logger, s.idResolver.Directory(), audience) - - xlogger := log.SubLogger(s.logger, "xrpc") - x := &avxrpc.Xrpc{ - DB: s.db, - Config: s.config, - Logger: xlogger, - ServiceAuth: sa, - IdResolver: s.idResolver, - Cloudflare: s.cfClient, - CodeSearch: s.codesearch, - Webhooks: whnotify.NewNotifier(s.db, s.config.Core.BaseUrl(), s.config.Core.Dev), - DisallowedNicknames: userutil.LoadDisallowedNicknames(s.config.Core.DisallowedNicknamesFile, xlogger), - } - return x.Router() -} diff --git a/appview/xrpc/account.go b/appview/xrpc/account.go deleted file mode 100644 index 8aef97ee..00000000 --- a/appview/xrpc/account.go +++ /dev/null @@ -1,173 +0,0 @@ -package xrpc - -import ( - "database/sql" - "encoding/json" - "errors" - "net/http" - "strings" - - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" - "tangled.org/core/appview/email" - xrpcerr "tangled.org/core/xrpc/errors" -) - -func (x *Xrpc) AccountListEmails(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountListEmails") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - emails, err := db.GetAllEmails(x.DB, did) - if err != nil { - l.Error("failed to get emails", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - items := make([]*tangled.TempAccountListEmails_Email, 0, len(emails)) - for _, e := range emails { - items = append(items, &tangled.TempAccountListEmails_Email{ - Address: e.Address, - Verified: e.Verified, - Primary: e.Primary, - CreatedAt: e.CreatedAt.UTC().Format(timeFormat), - }) - } - - x.writeJSON(w, &tangled.TempAccountListEmails_Output{Emails: items}) -} - -func (x *Xrpc) AccountDeleteEmail(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountDeleteEmail") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempAccountDeleteEmail_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - addr := strings.TrimSpace(input.Email) - - existing, err := db.GetEmail(x.DB, did, addr) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - writeError(w, xrpcErrorTag("EmailNotFound", "the email address is not associated with this account"), http.StatusNotFound) - return - } - l.Error("failed to get email", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if existing.Primary { - writeError(w, xrpcErrorTag("CannotDeletePrimary", "the primary email address cannot be deleted; set another address as primary first"), http.StatusBadRequest) - return - } - - if err := db.DeleteEmail(x.DB, did, addr); err != nil { - l.Error("failed to delete email", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) AccountSetPrimaryEmail(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountSetPrimaryEmail") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempAccountSetPrimaryEmail_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - addr := strings.TrimSpace(input.Email) - - existing, err := db.GetEmail(x.DB, did, addr) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - writeError(w, xrpcErrorTag("EmailNotFound", "the email address is not associated with this account"), http.StatusNotFound) - return - } - l.Error("failed to get email", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if !existing.Verified { - writeError(w, xrpcErrorTag("EmailNotVerified", "the email address must be verified before it can be made primary"), http.StatusBadRequest) - return - } - - if err := db.MakeEmailPrimary(x.DB, did, addr); err != nil { - l.Error("failed to set primary email", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) AccountSubscribeNewsletter(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountSubscribeNewsletter") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - primary, err := db.GetPrimaryEmail(x.DB, did) - if err != nil || primary.Address == "" { - writeError(w, xrpcErrorTag("NoVerifiedEmail", "a primary email address is required to subscribe"), http.StatusBadRequest) - return - } - - if err := db.UpsertNewsletterPref(x.DB, did, db.NewsletterStatusSubscribed, primary.Address); err != nil { - l.Error("failed to persist newsletter preference", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - if x.Config.Resend.ApiKey != "" && x.Config.Resend.NewsletterSegmentId != "" { - go func() { - if err := email.AddNewsletterContact(x.Config.Resend.ApiKey, x.Config.Resend.NewsletterSegmentId, primary.Address); err != nil { - l.Error("failed to add newsletter contact", "err", err) - } - }() - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) AccountDismissNewsletter(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountDismissNewsletter") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - if err := db.UpsertNewsletterPref(x.DB, did, db.NewsletterStatusDismissed, ""); err != nil { - l.Error("failed to persist newsletter dismissal", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} diff --git a/appview/xrpc/focus.go b/appview/xrpc/focus.go deleted file mode 100644 index bde72db9..00000000 --- a/appview/xrpc/focus.go +++ /dev/null @@ -1,119 +0,0 @@ -package xrpc - -import ( - "encoding/json" - "net/http" - - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - xrpcerr "tangled.org/core/xrpc/errors" -) - -func (x *Xrpc) FocusBegin(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "FocusBegin") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - if err := db.BeginFocus(x.DB, did); err != nil { - l.Error("failed to begin focus", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - item, err := db.GetNextFocusItem(x.DB, did) - if err != nil { - l.Error("failed to get first focus item", "err", err) - _ = db.EndFocus(x.DB, did) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - if item == nil { - _ = db.EndFocus(x.DB, did) - x.writeJSON(w, &tangled.TempFocusBeginSession_Output{}) - return - } - - out := &tangled.TempFocusBeginSession_Output{NotificationId: &item.ID} - setFocusSubject(item, &out.RepoDid, &out.IssueAt, &out.PullAt) - - x.writeJSON(w, out) -} - -func (x *Xrpc) FocusNext(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "FocusNext") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempFocusNextItem_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - if err := db.MarkNotificationRead(x.DB, input.CurrentId, did); err != nil { - l.Warn("failed to mark notification read", "id", input.CurrentId, "err", err) - } - - item, err := db.GetNextFocusItem(x.DB, did) - if err != nil { - l.Error("failed to get next focus item", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - if item == nil { - _ = db.EndFocus(x.DB, did) - x.writeJSON(w, &tangled.TempFocusNextItem_Output{}) - return - } - - out := &tangled.TempFocusNextItem_Output{NotificationId: &item.ID} - setFocusSubject(item, &out.RepoDid, &out.IssueAt, &out.PullAt) - - x.writeJSON(w, out) -} - -func (x *Xrpc) FocusEnd(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "FocusEnd") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - if err := db.EndFocus(x.DB, did); err != nil { - l.Error("failed to end focus", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -// setFocusSubject fills the repo did and (issue|pull) at-uri of a focus item so -// the client can navigate to it. -func setFocusSubject(n *models.NotificationWithEntity, repoDid, issueAt, pullAt **string) { - if n.Repo != nil { - s := n.Repo.RepoDid - *repoDid = &s - } - if n.Issue != nil { - s := n.Issue.AtUri().String() - *issueAt = &s - } - if n.Pull != nil { - s := n.Pull.AtUri().String() - *pullAt = &s - } -} diff --git a/appview/xrpc/notifications.go b/appview/xrpc/notifications.go deleted file mode 100644 index fa7604d6..00000000 --- a/appview/xrpc/notifications.go +++ /dev/null @@ -1,279 +0,0 @@ -package xrpc - -import ( - "encoding/json" - "net/http" - "strconv" - - "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - "tangled.org/core/appview/pagination" - "tangled.org/core/orm" - xrpcerr "tangled.org/core/xrpc/errors" -) - -func (x *Xrpc) NotificationList(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationList") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - q := r.URL.Query() - readFilter := q.Get("read") - categoryFilter := q.Get("category") - - filters := []orm.Filter{orm.FilterEq("recipient_did", did)} - if readFilter == "unread" { - filters = append(filters, orm.FilterEq("read", 0)) - } - switch categoryFilter { - case "social": - filters = append(filters, orm.FilterIn("type", models.SocialNotificationTypes)) - case "work": - filters = append(filters, orm.FilterIn("type", models.WorkNotificationTypes)) - } - - limit := 50 - if s := q.Get("limit"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { - limit = n - } - } - - notifications, err := db.GetNotificationsWithEntities(x.DB, pagination.Page{Limit: limit}, filters...) - if err != nil { - l.Error("failed to get notifications", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - unreadBase := []orm.Filter{ - orm.FilterEq("recipient_did", did), - orm.FilterEq("read", 0), - } - workUnread, _ := db.CountNotifications(x.DB, - append(unreadBase, orm.FilterIn("type", models.WorkNotificationTypes))...) - socialUnread, _ := db.CountNotifications(x.DB, - append(unreadBase, orm.FilterIn("type", models.SocialNotificationTypes))...) - - items := make([]*tangled.TempNotificationListNotifications_Notification, 0, len(notifications)) - for _, n := range notifications { - item := &tangled.TempNotificationListNotifications_Notification{ - Id: n.ID, - Type: string(n.Type), - Category: notificationCategory(n.Type), - ActorDid: n.ActorDid, - Read: n.Read, - CreatedAt: n.Created.UTC().Format("2006-01-02T15:04:05.000Z"), - } - if n.Repo != nil { - s := n.Repo.RepoDid - item.RepoDid = &s - } - if n.Issue != nil { - s := n.Issue.AtUri().String() - item.IssueAt = &s - } - if n.Pull != nil { - s := n.Pull.AtUri().String() - item.PullAt = &s - } - items = append(items, item) - } - - x.writeJSON(w, &tangled.TempNotificationListNotifications_Output{ - Notifications: items, - WorkUnreadCount: workUnread, - SocialUnreadCount: socialUnread, - }) -} - -func (x *Xrpc) NotificationGetUnreadCount(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationGetUnreadCount") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - count, err := db.CountNotifications(x.DB, - orm.FilterEq("recipient_did", did), - orm.FilterEq("read", 0), - ) - if err != nil { - l.Error("failed to count unread notifications", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - x.writeJSON(w, &tangled.TempNotificationGetUnreadCount_Output{Count: count}) -} - -func (x *Xrpc) NotificationUpdateSeen(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationUpdateSeen") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempNotificationUpdateSeen_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - var err error - if input.Read { - err = db.MarkNotificationRead(x.DB, input.Id, did) - } else { - err = db.MarkNotificationUnread(x.DB, input.Id, did) - } - if err != nil { - l.Error("failed to update notification read state", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) NotificationMarkAllRead(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationMarkAllRead") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - if err := db.MarkAllNotificationsRead(x.DB, did); err != nil { - l.Error("failed to mark all notifications read", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) NotificationDelete(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationDelete") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempNotificationDeleteNotification_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - if err := db.DeleteNotification(x.DB, input.Id, did); err != nil { - l.Error("failed to delete notification", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) NotificationGetPreferences(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationGetPreferences") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - prefs, err := db.GetNotificationPreference(x.DB, did) - if err != nil { - l.Error("failed to get notification preferences", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - x.writeJSON(w, &tangled.TempNotificationGetPreferences_Preferences{ - EmailNotifications: prefs.EmailNotifications, - Followed: prefs.Followed, - IssueClosed: prefs.IssueClosed, - IssueCommented: prefs.IssueCommented, - IssueCreated: prefs.IssueCreated, - PullCommented: prefs.PullCommented, - PullCreated: prefs.PullCreated, - PullMerged: prefs.PullMerged, - RepoStarred: prefs.RepoStarred, - UserMentioned: prefs.UserMentioned, - }) -} - -func (x *Xrpc) NotificationUpdatePreferences(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "NotificationUpdatePreferences") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempNotificationUpdatePreferences_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - existing, err := db.GetNotificationPreference(x.DB, did) - if err != nil { - l.Error("failed to get existing notification preferences", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - prefs := &models.NotificationPreferences{ - UserDid: syntax.DID(did), - RepoStarred: applyBoolPtr(existing.RepoStarred, input.RepoStarred), - IssueCreated: applyBoolPtr(existing.IssueCreated, input.IssueCreated), - IssueCommented: applyBoolPtr(existing.IssueCommented, input.IssueCommented), - IssueClosed: applyBoolPtr(existing.IssueClosed, input.IssueClosed), - PullCreated: applyBoolPtr(existing.PullCreated, input.PullCreated), - PullCommented: applyBoolPtr(existing.PullCommented, input.PullCommented), - PullMerged: applyBoolPtr(existing.PullMerged, input.PullMerged), - Followed: applyBoolPtr(existing.Followed, input.Followed), - UserMentioned: applyBoolPtr(existing.UserMentioned, input.UserMentioned), - EmailNotifications: applyBoolPtr(existing.EmailNotifications, input.EmailNotifications), - } - - if err := x.DB.UpdateNotificationPreferences(r.Context(), prefs); err != nil { - l.Error("failed to update notification preferences", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func notificationCategory(t models.NotificationType) string { - for _, st := range models.SocialNotificationTypes { - if st == t { - return "social" - } - } - return "work" -} - -func applyBoolPtr(existing bool, update *bool) bool { - if update != nil { - return *update - } - return existing -} diff --git a/appview/xrpc/search.go b/appview/xrpc/search.go deleted file mode 100644 index a9cfe288..00000000 --- a/appview/xrpc/search.go +++ /dev/null @@ -1,157 +0,0 @@ -package xrpc - -import ( - "errors" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/sourcegraph/zoekt" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/codesearch" - "tangled.org/core/appview/pagination" -) - -func (x *Xrpc) SearchSearchCode(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SearchSearchCode") - - if x.CodeSearch == nil { - writeError(w, notImplementedError("code search is not configured"), http.StatusNotImplemented) - return - } - - q := r.URL.Query() - rawQuery := strings.TrimSpace(q.Get("q")) - if rawQuery == "" { - writeError(w, badRequestError("missing required parameter: q"), http.StatusBadRequest) - return - } - - // scope by the repo's own did (meta.did) and language; post-filtered below - var scope []string - var repoFilter syntax.DID - if raw := strings.TrimSpace(q.Get("repoDid")); raw != "" { - repoDid, err := syntax.ParseDID(raw) - if err != nil { - writeError(w, badRequestError("invalid repoDid"), http.StatusBadRequest) - return - } - repoFilter = repoDid - scope = append(scope, fmt.Sprintf("meta.did:%s", repoDid)) - } - if lang := strings.TrimSpace(q.Get("lang")); lang != "" { - scope = append(scope, fmt.Sprintf("lang:%s", lang)) - } - queryStr := strings.TrimSpace(strings.Join(scope, " ") + " " + rawQuery) - - page := pagination.Page{Limit: 50} - if s := q.Get("limit"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { - page.Limit = n - } - } - if s := q.Get("cursor"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n >= 0 { - page.Offset = n - } - } - - res, err := x.CodeSearch.Search(r.Context(), queryStr, page) - if err != nil { - var repoErr *codesearch.RepoOnlyError - if errors.As(err, &repoErr) { - writeError(w, badRequestError("query only filters by repo name; use repo search instead"), http.StatusBadRequest) - return - } - l.Error("code search failed", "err", err, "query", queryStr) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - // meta.did is best-effort, so drop anything that isn't the requested repo - filtered := res.Results - if repoFilter != "" { - filtered = filtered[:0] - for _, item := range res.Results { - if item.RepoDID == repoFilter { - filtered = append(filtered, item) - } - } - } - - results := make([]*tangled.TempSearchSearchCode_FileResult, 0, len(filtered)) - for _, item := range filtered { - fr := &tangled.TempSearchSearchCode_FileResult{ - RepoDid: item.RepoDID.String(), - Path: item.FilePath, - } - if item.Language != "" { - lang := item.Language - fr.Language = &lang - } - for _, c := range item.Chunks { - fr.Chunks = append(fr.Chunks, &tangled.TempSearchSearchCode_Chunk{ - Content: c.Content, - LineStart: int64(c.ContentStartLine), - Highlights: chunkHighlights(c.Content, c.ContentStartLine, c.Ranges), - }) - } - results = append(results, fr) - } - - out := &tangled.TempSearchSearchCode_Output{Results: results} - if res.HasMore { - cursor := strconv.Itoa(page.Offset + page.Limit) - out.Cursor = &cursor - } - - x.writeJSON(w, out) -} - -// chunkHighlights maps zoekt (line, rune-column) ranges to byte-offset ranges -// within the chunk's content string -func chunkHighlights(content string, startLine int, ranges []zoekt.Range) []*tangled.TempSearchSearchCode_Highlight { - if startLine < 1 { - startLine = 1 - } - lines := strings.SplitAfter(content, "\n") - lineOffset := make([]int, len(lines)) - off := 0 - for i, ln := range lines { - lineOffset[i] = off - off += len(ln) - } - - // byteAt maps a 1-based (line, rune column) to a byte offset in content - byteAt := func(lineNum, runeCol int) int { - idx := lineNum - startLine - if idx < 0 || idx >= len(lines) { - return -1 - } - col := runeCol - 1 - if col < 0 { - col = 0 - } - r := 0 - for bi := range lines[idx] { - if r == col { - return lineOffset[idx] + bi - } - r++ - } - return lineOffset[idx] + len(strings.TrimSuffix(lines[idx], "\n")) - } - - var out []*tangled.TempSearchSearchCode_Highlight - for _, rg := range ranges { - s := byteAt(int(rg.Start.LineNumber), int(rg.Start.Column)) - e := byteAt(int(rg.End.LineNumber), int(rg.End.Column)) - if s < 0 || e < 0 || e <= s { - continue - } - out = append(out, &tangled.TempSearchSearchCode_Highlight{Start: int64(s), End: int64(e)}) - } - return out -} diff --git a/appview/xrpc/signup.go b/appview/xrpc/signup.go deleted file mode 100644 index 07c39635..00000000 --- a/appview/xrpc/signup.go +++ /dev/null @@ -1,306 +0,0 @@ -package xrpc - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" - "tangled.org/core/appview/email" - "tangled.org/core/appview/models" - "tangled.org/core/appview/state/userutil" -) - -func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountBeginSignup") - - // signup is gated on cloudflare being configured, mirroring appview/signup - if x.Cloudflare == nil { - writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) - return - } - - var input tangled.TempAccountBeginSignup_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - if err := x.validateTurnstile(input.TurnstileToken, r); err != nil { - l.Warn("turnstile validation failed", "err", err, "email", input.Email) - writeError(w, xrpcErrorTag("InvalidTurnstileToken", "captcha validation failed"), http.StatusForbidden) - return - } - - if !email.IsValidEmail(input.Email) { - writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) - return - } - - exists, err := db.CheckEmailExistsAtAll(x.DB, input.Email) - if err != nil { - l.Error("failed to check email existence", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if exists { - writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) - return - } - - // the verification code is an invite code minted by the PDS - code, err := x.pdsCreateInviteCode() - if err != nil { - l.Error("failed to create invite code", "err", err) - writeError(w, errUpstream, http.StatusBadGateway) - return - } - - em := email.Email{ - APIKey: x.Config.Resend.ApiKey, - From: x.Config.Resend.SentFrom, - To: input.Email, - Subject: "Verify your Tangled account", - Text: "Copy and paste this code below to verify your account on Tangled.\n" + code, - Html: "

Copy and paste this code below to verify your account on Tangled.

\n

" + code + "

", - } - if err := email.SendEmail(em); err != nil { - l.Error("failed to send verification email", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: input.Email, InviteCode: code}); err != nil { - l.Error("failed to add inflight signup", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "AccountCompleteSignup") - - if x.Cloudflare == nil { - writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) - return - } - - var input tangled.TempAccountCompleteSignup_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - if !userutil.IsValidSubdomain(input.Username) { - writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest) - return - } - if x.DisallowedNicknames[strings.ToLower(input.Username)] { - writeError(w, xrpcErrorTag("UsernameUnavailable", "this username is not available"), http.StatusConflict) - return - } - - emailAddr, err := db.GetEmailForCode(x.DB, input.Code) - if err != nil { - l.Error("failed to get email for code", "err", err) - writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest) - return - } - - did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code) - if err != nil { - l.Error("failed to provision account", "err", err) - writeError(w, errUpstream, http.StatusBadGateway) - return - } - - go func() { - if err := db.DeleteInflightSignup(x.DB, emailAddr); err != nil { - l.Error("failed to delete inflight signup", "err", err) - } - }() - - x.writeJSON(w, &tangled.TempAccountCompleteSignup_Output{Did: did, Handle: handle}) -} - -// provisionAccount creates the pds account, records its verified primary email, -// and auto-claims the sites subdomain, rolling back on failure. -func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did, handle string, err error) { - success := false - emailAdded := false - defer func() { - if success { - return - } - x.Logger.Info("rolling back signup", "username", username, "did", did) - if did != "" { - if derr := x.pdsDeleteAccount(did); derr != nil { - x.Logger.Error("failed to roll back PDS account", "err", derr, "did", did) - } - } - if emailAdded { - if derr := db.DeleteEmail(x.DB, did, emailAddr); derr != nil { - x.Logger.Error("failed to roll back email row", "err", derr, "email", emailAddr) - } - } - }() - - did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code) - if err != nil { - return "", "", err - } - - if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil { - return "", "", err - } - emailAdded = true - - // auto-claim .: the only way to get a pds-domain site - pdsDomain := strings.TrimPrefix(x.Config.Pds.Host, "https://") - pdsDomain = strings.TrimPrefix(pdsDomain, "http://") - autoClaim := username + "." + pdsDomain - if err := db.ClaimDomain(x.DB, did, autoClaim); err != nil { - x.Logger.Warn("failed to auto-claim sites domain at signup", "domain", autoClaim, "did", did, "err", err) - } - - success = true - return did, handle, nil -} - -func (x *Xrpc) validateTurnstile(token string, r *http.Request) error { - if token == "" { - return errors.New("captcha token is empty") - } - if x.Config.Cloudflare.Turnstile.SecretKey == "" { - return errors.New("turnstile secret key not configured") - } - - data := url.Values{} - data.Set("secret", x.Config.Cloudflare.Turnstile.SecretKey) - data.Set("response", token) - if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { - data.Set("remoteip", ip) - } else if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { - if parts := strings.Split(fwd, ","); len(parts) > 0 { - data.Set("remoteip", strings.TrimSpace(parts[0])) - } - } else { - data.Set("remoteip", r.RemoteAddr) - } - - resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) - if err != nil { - return fmt.Errorf("failed to verify turnstile token: %w", err) - } - defer resp.Body.Close() - - var tr struct { - Success bool `json:"success"` - ErrorCodes []string `json:"error-codes,omitempty"` - } - if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { - return fmt.Errorf("failed to decode turnstile response: %w", err) - } - if !tr.Success { - return fmt.Errorf("turnstile validation failed: %v", tr.ErrorCodes) - } - return nil -} - -// pdsRequest posts to a pds xrpc endpoint; useAuth sends the admin secret via -// basic auth. these are unauth'd or admin-authed, so they use raw http. -func (x *Xrpc) pdsRequest(endpoint string, body any, useAuth bool) (*http.Response, error) { - jsonData, err := json.Marshal(body) - if err != nil { - return nil, err - } - u := fmt.Sprintf("%s/xrpc/%s", x.Config.Pds.Host, endpoint) - req, err := http.NewRequest(http.MethodPost, u, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - if useAuth { - req.SetBasicAuth("admin", x.Config.Pds.AdminSecret) - } - return http.DefaultClient.Do(req) -} - -func pdsError(resp *http.Response, action string) error { - var e struct { - Error string `json:"error"` - Message string `json:"message"` - } - b, _ := io.ReadAll(resp.Body) - if err := json.Unmarshal(b, &e); err == nil && e.Message != "" { - return fmt.Errorf("failed to %s: %s - %s", action, e.Error, e.Message) - } - return fmt.Errorf("failed to %s, status %d", action, resp.StatusCode) -} - -func (x *Xrpc) pdsCreateInviteCode() (string, error) { - resp, err := x.pdsRequest("com.atproto.server.createInviteCode", map[string]any{"useCount": 1}, true) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", pdsError(resp, "create invite code") - } - var result map[string]string - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to decode invite code response: %w", err) - } - return result["code"], nil -} - -func (x *Xrpc) pdsCreateAccount(username, password, emailAddr, code string) (did, handle string, err error) { - parsed, err := url.Parse(x.Config.Pds.Host) - if err != nil { - return "", "", fmt.Errorf("invalid PDS host URL: %w", err) - } - handle = fmt.Sprintf("%s.%s", username, parsed.Hostname()) - - body := map[string]string{ - "email": emailAddr, - "handle": handle, - "password": password, - "inviteCode": code, - } - resp, err := x.pdsRequest("com.atproto.server.createAccount", body, false) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", "", pdsError(resp, "create account") - } - - var result struct { - DID string `json:"did"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", "", fmt.Errorf("failed to decode create account response: %w", err) - } - return result.DID, handle, nil -} - -func (x *Xrpc) pdsDeleteAccount(did string) error { - resp, err := x.pdsRequest("com.atproto.admin.deleteAccount", map[string]string{"did": did}, true) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return pdsError(resp, "delete account") - } - return nil -} diff --git a/appview/xrpc/sites.go b/appview/xrpc/sites.go deleted file mode 100644 index 15210f39..00000000 --- a/appview/xrpc/sites.go +++ /dev/null @@ -1,329 +0,0 @@ -package xrpc - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "path" - "strings" - - "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - "tangled.org/core/appview/sites" - "tangled.org/core/appview/state/userutil" - xrpcerr "tangled.org/core/xrpc/errors" -) - -func (x *Xrpc) SiteGetDomainClaim(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SiteGetDomainClaim") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - claim, err := db.GetActiveDomainClaimForDid(x.DB, did) - if err != nil { - l.Error("failed to get domain claim", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - out := &tangled.TempSiteGetDomainClaim_Output{} - if claim != nil { - out.Domain = &claim.Domain - } - x.writeJSON(w, out) -} - -func (x *Xrpc) SiteClaimDomain(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SiteClaimDomain") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempSiteClaimDomain_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - subdomain := strings.TrimSpace(input.Subdomain) - if len(subdomain) < 4 { - writeError(w, xrpcErrorTag("InvalidSubdomain", "subdomain must be at least 4 characters long"), http.StatusBadRequest) - return - } - if !userutil.IsValidSubdomain(subdomain) { - writeError(w, xrpcErrorTag("InvalidSubdomain", "use only lowercase letters, digits, and hyphens; cannot start or end with a hyphen"), http.StatusBadRequest) - return - } - if userutil.HasSlur(subdomain) { - writeError(w, xrpcErrorTag("InvalidSubdomain", "that subdomain is not allowed"), http.StatusBadRequest) - return - } - - sitesDomain := x.Config.Sites.Domain - if subdomain == sitesDomain { - writeError(w, xrpcErrorTag("InvalidSubdomain", "cannot claim the root domain"), http.StatusBadRequest) - return - } - fullDomain := subdomain + "." + sitesDomain - - if err := db.ClaimDomain(x.DB, did, fullDomain); err != nil { - switch { - case errors.Is(err, db.ErrDomainTaken): - writeError(w, xrpcErrorTag("DomainTaken", err.Error()), http.StatusConflict) - case errors.Is(err, db.ErrDomainCooldown): - writeError(w, xrpcErrorTag("DomainCooldown", err.Error()), http.StatusConflict) - case errors.Is(err, db.ErrAlreadyClaimed): - writeError(w, xrpcErrorTag("AlreadyClaimed", err.Error()), http.StatusConflict) - default: - l.Error("claiming domain", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - } - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) SiteReleaseDomain(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SiteReleaseDomain") - - did, ok := actorDid(r) - if !ok { - writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) - return - } - - var input tangled.TempSiteReleaseDomain_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - domain := strings.TrimSpace(input.Domain) - if domain == "" { - writeError(w, badRequestError("domain cannot be empty"), http.StatusBadRequest) - return - } - - // a tngl.sh handle's sites domain is auto-claimed at signup and handle-bound - if isTngl, err := x.isTnglHandle(r.Context(), did); err != nil { - l.Error("resolving identity", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } else if isTngl { - writeError(w, xrpcErrorTag("HandleBoundDomain", "your tngl.sh domain is tied to your handle and cannot be released"), http.StatusBadRequest) - return - } - - if err := db.ReleaseDomain(x.DB, did, domain); err != nil { - l.Error("releasing domain", "err", err) - writeError(w, xrpcErrorTag("DomainNotFound", "unable to release domain; ensure it belongs to your account"), http.StatusNotFound) - return - } - - // clean up all site data for this did asynchronously - if x.Cloudflare != nil && x.Cloudflare.Enabled() { - siteConfigs, err := db.GetRepoSiteConfigsForDid(x.DB, did) - if err != nil { - l.Error("fetching site configs for cleanup", "err", err) - } - if err := db.DeleteRepoSiteConfigsForDid(x.DB, did); err != nil { - l.Error("deleting site configs from db", "err", err) - } - - go func() { - ctx := context.Background() - for _, sc := range siteConfigs { - if err := sites.Delete(ctx, x.Cloudflare, did, sc.RepoRkey); err != nil { - l.Error("R2 delete failed", "did", did, "repo", sc.RepoRkey, "err", err) - } - } - if err := sites.DeleteAllDomainMappings(ctx, x.Cloudflare, domain); err != nil { - l.Error("KV delete failed", "domain", domain, "err", err) - } - }() - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) SiteGetRepoSiteConfig(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SiteGetRepoSiteConfig") - - repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - config, err := db.GetRepoSiteConfig(x.DB, repo.RepoDid) - if err != nil { - l.Error("failed to get repo site config", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - out := &tangled.TempRepoGetSiteConfig_Output{} - if config != nil { - out.Config = &tangled.TempRepoGetSiteConfig_SiteConfig{ - Branch: config.Branch, - Dir: config.Dir, - IsIndex: config.IsIndex, - } - } - x.writeJSON(w, out) -} - -func (x *Xrpc) SiteUpdateRepoSiteConfig(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SiteUpdateRepoSiteConfig") - - var input tangled.TempRepoUpdateSiteConfig_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - branch := strings.TrimSpace(input.Branch) - if branch == "" { - writeError(w, badRequestError("branch cannot be empty"), http.StatusBadRequest) - return - } - - dir := strings.TrimSpace(input.Dir) - if dir == "" { - dir = "/" - } - dir = path.Clean("/" + dir) - if dir != "/" && strings.Contains(dir, "..") { - writeError(w, badRequestError("invalid directory path"), http.StatusBadRequest) - return - } - - isIndex := input.IsIndex != nil && *input.IsIndex - - // check the claim before persisting, so a failed call leaves no state - ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) - if ownerClaim == nil { - writeError(w, xrpcErrorTag("NoDomainClaim", "the account does not have an active domain claim"), http.StatusBadRequest) - return - } - - if err := db.SetRepoSiteConfig(x.DB, repo.RepoDid, branch, dir, isIndex); err != nil { - l.Error("failed to save site config", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - if x.Cloudflare != nil && x.Cloudflare.Enabled() { - go x.deploySite(repo, branch, dir, isIndex, ownerClaim.Domain) - } else { - l.Warn("cloudflare integration disabled; site won't be deployed", "repo", repo.RepoIdentifier()) - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) SiteDisableRepoSite(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "SiteDisableRepoSite") - - var input tangled.TempRepoDisableSite_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - existingConfig, _ := db.GetRepoSiteConfig(x.DB, repo.RepoDid) - if existingConfig == nil { - writeError(w, xrpcErrorTag("SiteNotFound", "no site configuration exists for this repository"), http.StatusNotFound) - return - } - - if err := db.DeleteRepoSiteConfig(x.DB, repo.RepoDid); err != nil { - l.Error("failed to delete site config", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - if x.Cloudflare != nil && x.Cloudflare.Enabled() { - ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) - go func() { - ctx := context.Background() - if err := sites.Delete(ctx, x.Cloudflare, repo.Did, repo.Rkey); err != nil { - l.Error("R2 delete failed", "repo", repo.RepoIdentifier(), "err", err) - } - if ownerClaim != nil { - if err := sites.DeleteDomainMapping(ctx, x.Cloudflare, ownerClaim.Domain, repo.Name); err != nil { - l.Error("KV delete failed", "domain", ownerClaim.Domain, "err", err) - } - } - }() - } - - w.WriteHeader(http.StatusOK) -} - -// deploySite syncs a repo's site to r2 and writes the domain mapping, mirroring -// the appview's SaveRepoSiteConfig deploy path -func (x *Xrpc) deploySite(repo *models.Repo, branch, dir string, isIndex bool, domain string) { - l := x.Logger.With("handler", "deploySite", "repo", repo.RepoIdentifier()) - ctx := context.Background() - - deploy := &models.SiteDeploy{ - RepoDid: syntax.DID(repo.RepoDid), - Branch: branch, - Dir: dir, - Trigger: models.SiteDeployTriggerConfigChange, - } - - deployErr := sites.Deploy(ctx, x.Cloudflare, x.Config, repo, branch, dir) - if deployErr != nil { - l.Error("initial R2 sync failed", "err", deployErr) - deploy.Status = models.SiteDeployStatusFailure - deploy.Error = deployErr.Error() - } else { - deploy.Status = models.SiteDeployStatusSuccess - } - - if err := db.AddSiteDeploy(x.DB, deploy); err != nil { - l.Error("failed to record deploy", "err", err) - } - - if deployErr == nil { - if err := sites.PutDomainMapping(ctx, x.Cloudflare, domain, repo.Did, repo.Name, repo.Rkey, isIndex); err != nil { - l.Error("KV write failed", "domain", domain, "err", err) - } - } -} - -// isTnglHandle reports whether the account's handle sits under the PDS user -// domain (e.g. *.tngl.sh). Such users have a handle-bound sites domain that was -// auto-claimed at signup and must not be released. -func (x *Xrpc) isTnglHandle(ctx context.Context, did string) (bool, error) { - ident, err := x.IdResolver.ResolveIdent(ctx, did) - if err != nil { - return false, err - } - return strings.HasSuffix(ident.Handle.String(), x.Config.Pds.UserDomain), nil -} diff --git a/appview/xrpc/webhooks.go b/appview/xrpc/webhooks.go deleted file mode 100644 index 50b9b40b..00000000 --- a/appview/xrpc/webhooks.go +++ /dev/null @@ -1,382 +0,0 @@ -package xrpc - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - "strings" - - "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - "tangled.org/core/hostutil" - xrpcerr "tangled.org/core/xrpc/errors" -) - -// resolveOwnedRepo loads the repo by its DID and checks the actor owns it -func (x *Xrpc) resolveOwnedRepo(r *http.Request, repoDid string) (*models.Repo, *xrpcerr.XrpcError, int) { - did, ok := actorDid(r) - if !ok { - e := xrpcerr.MissingActorDidError - return nil, &e, http.StatusForbidden - } - - repo, err := db.GetRepoByDid(x.DB, repoDid) - if err != nil { - e := notFoundError("repo not found") - return nil, &e, http.StatusNotFound - } - - if repo.Did != did { - e := xrpcerr.AccessControlError(did) - return nil, &e, http.StatusForbidden - } - - return repo, nil, http.StatusOK -} - -func (x *Xrpc) WebhookList(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "WebhookList") - - repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - webhooks, err := db.GetWebhooksForRepo(x.DB, string(repo.RepoDid)) - if err != nil { - l.Error("failed to get webhooks", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - items := make([]*tangled.TempRepoListWebhooks_Webhook, 0, len(webhooks)) - for i := range webhooks { - wh := &webhooks[i] - updated := wh.UpdatedAt.UTC().Format(timeFormat) - items = append(items, &tangled.TempRepoListWebhooks_Webhook{ - Id: wh.Id, - Url: wh.Url, - Active: wh.Active, - Events: wh.Events, - CreatedAt: wh.CreatedAt.UTC().Format(timeFormat), - UpdatedAt: &updated, - }) - } - - x.writeJSON(w, &tangled.TempRepoListWebhooks_Output{Webhooks: items}) -} - -func (x *Xrpc) WebhookCreate(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "WebhookCreate") - - var input tangled.TempRepoCreateWebhook_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - url := strings.TrimSpace(input.Url) - if err := hostutil.ValidateExternalURL(url, x.Config.Core.Dev); err != nil { - writeError(w, badRequestError(err.Error()), http.StatusBadRequest) - return - } - if len(input.Events) == 0 { - writeError(w, xrpcErrorTag("NoEventsSelected", "at least one event must be specified"), http.StatusBadRequest) - return - } - - active := true - if input.Active != nil { - active = *input.Active - } - secret := "" - if input.Secret != nil { - secret = strings.TrimSpace(*input.Secret) - } - - webhook := &models.Webhook{ - RepoDid: syntax.DID(repo.RepoDid), - Url: url, - Secret: secret, - Active: active, - Events: input.Events, - } - - tx, err := x.DB.Begin() - if err != nil { - l.Error("failed to start transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - defer tx.Rollback() - - if err := db.AddWebhook(tx, webhook); err != nil { - l.Error("failed to add webhook", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - x.writeJSON(w, &tangled.TempRepoCreateWebhook_Output{Id: webhook.Id}) -} - -func (x *Xrpc) WebhookUpdate(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "WebhookUpdate") - - var input tangled.TempRepoUpdateWebhook_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - webhook, err := db.GetWebhook(x.DB, input.Id) - if err != nil || string(webhook.RepoDid) != repo.RepoDid { - writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) - return - } - - if input.Url != nil { - url := strings.TrimSpace(*input.Url) - if url != "" { - if err := hostutil.ValidateExternalURL(url, x.Config.Core.Dev); err != nil { - writeError(w, badRequestError(err.Error()), http.StatusBadRequest) - return - } - webhook.Url = url - } - } - if input.Secret != nil { - webhook.Secret = strings.TrimSpace(*input.Secret) - } - if input.Active != nil { - webhook.Active = *input.Active - } - if len(input.Events) > 0 { - webhook.Events = input.Events - } - - tx, err := x.DB.Begin() - if err != nil { - l.Error("failed to start transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - defer tx.Rollback() - - if err := db.UpdateWebhook(tx, webhook); err != nil { - l.Error("failed to update webhook", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) WebhookDelete(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "WebhookDelete") - - var input tangled.TempRepoDeleteWebhook_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - webhook, err := db.GetWebhook(x.DB, input.Id) - if err != nil || string(webhook.RepoDid) != repo.RepoDid { - writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) - return - } - - tx, err := x.DB.Begin() - if err != nil { - l.Error("failed to start transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - defer tx.Rollback() - - if err := db.DeleteWebhook(tx, input.Id); err != nil { - l.Error("failed to delete webhook", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (x *Xrpc) WebhookToggle(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "WebhookToggle") - - var input tangled.TempRepoToggleWebhook_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - webhook, err := db.GetWebhook(x.DB, input.Id) - if err != nil || string(webhook.RepoDid) != repo.RepoDid { - writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) - return - } - - webhook.Active = !webhook.Active - - tx, err := x.DB.Begin() - if err != nil { - l.Error("failed to start transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - defer tx.Rollback() - - if err := db.UpdateWebhook(tx, webhook); err != nil { - l.Error("failed to toggle webhook", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - x.writeJSON(w, &tangled.TempRepoToggleWebhook_Output{Active: webhook.Active}) -} - -func (x *Xrpc) WebhookListDeliveries(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "WebhookListDeliveries") - - q := r.URL.Query() - repo, xerr, status := x.resolveOwnedRepo(r, q.Get("repoDid")) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - id, err := strconv.ParseInt(q.Get("id"), 10, 64) - if err != nil { - writeError(w, badRequestError("invalid webhook id"), http.StatusBadRequest) - return - } - - webhook, err := db.GetWebhook(x.DB, id) - if err != nil || string(webhook.RepoDid) != repo.RepoDid { - writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) - return - } - - limit := 100 - if s := q.Get("limit"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { - limit = n - } - } - - deliveries, err := db.GetWebhookDeliveries(x.DB, webhook.Id, limit) - if err != nil { - l.Error("failed to get webhook deliveries", "err", err) - writeError(w, errInternal, http.StatusInternalServerError) - return - } - - items := make([]*tangled.TempRepoListWebhookDeliveries_Delivery, 0, len(deliveries)) - for i := range deliveries { - d := &deliveries[i] - item := &tangled.TempRepoListWebhookDeliveries_Delivery{ - Id: d.Id, - DeliveryId: d.DeliveryId, - Event: d.Event, - Url: d.Url, - Success: d.Success, - CreatedAt: d.CreatedAt.UTC().Format(timeFormat), - } - if d.RequestBody != "" { - rb := d.RequestBody - item.RequestBody = &rb - } - if d.ResponseBody != "" { - rb := d.ResponseBody - item.ResponseBody = &rb - } - if d.ResponseCode != 0 { - rc := int64(d.ResponseCode) - item.ResponseCode = &rc - } - items = append(items, item) - } - - x.writeJSON(w, &tangled.TempRepoListWebhookDeliveries_Output{Deliveries: items}) -} - -func (x *Xrpc) WebhookRetryDelivery(w http.ResponseWriter, r *http.Request) { - var input tangled.TempRepoRetryWebhookDelivery_Input - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeError(w, errBadRequestBody, http.StatusBadRequest) - return - } - - repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) - if xerr != nil { - writeError(w, *xerr, status) - return - } - - webhook, err := db.GetWebhook(x.DB, input.WebhookId) - if err != nil || string(webhook.RepoDid) != repo.RepoDid { - writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) - return - } - - delivery, err := db.GetWebhookDelivery(x.DB, input.DeliveryId) - if err != nil || delivery.WebhookId != webhook.Id { - writeError(w, xrpcErrorTag("DeliveryNotFound", "delivery not found"), http.StatusNotFound) - return - } - - // re-dispatch async; the new attempt is recorded as its own delivery - go x.Webhooks.Redeliver(context.Background(), *webhook, *delivery) - - w.WriteHeader(http.StatusOK) -} diff --git a/appview/xrpc/xrpc.go b/appview/xrpc/xrpc.go deleted file mode 100644 index 8c358e31..00000000 --- a/appview/xrpc/xrpc.go +++ /dev/null @@ -1,184 +0,0 @@ -package xrpc - -import ( - "encoding/json" - "log/slog" - "net/http" - "runtime/debug" - - "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/go-chi/chi/v5" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/cloudflare" - "tangled.org/core/appview/codesearch" - "tangled.org/core/appview/config" - "tangled.org/core/appview/db" - whnotify "tangled.org/core/appview/notify/webhook" - "tangled.org/core/idresolver" - xrpcerr "tangled.org/core/xrpc/errors" - "tangled.org/core/xrpc/serviceauth" -) - -const ActorDid = serviceauth.ActorDid - -type Xrpc struct { - DB *db.DB - Config *config.Config - Logger *slog.Logger - ServiceAuth *serviceauth.ServiceAuth - IdResolver *idresolver.Resolver - Cloudflare *cloudflare.Client - CodeSearch *codesearch.CodeSearch - Webhooks *whnotify.Notifier - - // reserved usernames rejected at signup completion - DisallowedNicknames map[string]bool -} - -func (x *Xrpc) Router() http.Handler { - r := chi.NewRouter() - - r.Use(x.cors) - - // health check, atproto _health convention - r.Get("/_health", x.health) - - // open endpoints: signup happens pre-identity, so no service auth - r.Post("/"+tangled.TempAccountBeginSignupNSID, x.AccountBeginSignup) - r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup) - - // authenticated endpoints - r.Group(func(r chi.Router) { - r.Use(x.ServiceAuth.VerifyServiceAuth) - - // code search is gated on login, matching the appview ui - r.Get("/"+tangled.TempSearchSearchCodeNSID, x.SearchSearchCode) - - // notifications - r.Get("/"+tangled.TempNotificationListNotificationsNSID, x.NotificationList) - r.Get("/"+tangled.TempNotificationGetUnreadCountNSID, x.NotificationGetUnreadCount) - r.Post("/"+tangled.TempNotificationUpdateSeenNSID, x.NotificationUpdateSeen) - r.Post("/"+tangled.TempNotificationMarkAllReadNSID, x.NotificationMarkAllRead) - r.Post("/"+tangled.TempNotificationDeleteNotificationNSID, x.NotificationDelete) - r.Get("/"+tangled.TempNotificationGetPreferencesNSID, x.NotificationGetPreferences) - r.Post("/"+tangled.TempNotificationUpdatePreferencesNSID, x.NotificationUpdatePreferences) - - // focus mode - r.Post("/"+tangled.TempFocusBeginSessionNSID, x.FocusBegin) - r.Post("/"+tangled.TempFocusNextItemNSID, x.FocusNext) - r.Post("/"+tangled.TempFocusEndSessionNSID, x.FocusEnd) - - // account management - r.Get("/"+tangled.TempAccountListEmailsNSID, x.AccountListEmails) - r.Post("/"+tangled.TempAccountDeleteEmailNSID, x.AccountDeleteEmail) - r.Post("/"+tangled.TempAccountSetPrimaryEmailNSID, x.AccountSetPrimaryEmail) - r.Post("/"+tangled.TempAccountSubscribeNewsletterNSID, x.AccountSubscribeNewsletter) - r.Post("/"+tangled.TempAccountDismissNewsletterNSID, x.AccountDismissNewsletter) - - // webhooks - r.Get("/"+tangled.TempRepoListWebhooksNSID, x.WebhookList) - r.Post("/"+tangled.TempRepoCreateWebhookNSID, x.WebhookCreate) - r.Post("/"+tangled.TempRepoUpdateWebhookNSID, x.WebhookUpdate) - r.Post("/"+tangled.TempRepoDeleteWebhookNSID, x.WebhookDelete) - r.Post("/"+tangled.TempRepoToggleWebhookNSID, x.WebhookToggle) - r.Get("/"+tangled.TempRepoListWebhookDeliveriesNSID, x.WebhookListDeliveries) - r.Post("/"+tangled.TempRepoRetryWebhookDeliveryNSID, x.WebhookRetryDelivery) - - // sites - r.Get("/"+tangled.TempSiteGetDomainClaimNSID, x.SiteGetDomainClaim) - r.Post("/"+tangled.TempSiteClaimDomainNSID, x.SiteClaimDomain) - r.Post("/"+tangled.TempSiteReleaseDomainNSID, x.SiteReleaseDomain) - r.Get("/"+tangled.TempRepoGetSiteConfigNSID, x.SiteGetRepoSiteConfig) - r.Post("/"+tangled.TempRepoUpdateSiteConfigNSID, x.SiteUpdateRepoSiteConfig) - r.Post("/"+tangled.TempRepoDisableSiteNSID, x.SiteDisableRepoSite) - }) - - return r -} - -// timeFormat is the datetime format used across lexicon output fields -const timeFormat = "2006-01-02T15:04:05.000Z" - -// health responds to /xrpc/_health with the running version -func (x *Xrpc) health(w http.ResponseWriter, r *http.Request) { - x.writeJSON(w, map[string]string{"version": serviceVersion()}) -} - -// serviceVersion returns the build's vcs revision, or "dev" -func serviceVersion() string { - info, ok := debug.ReadBuildInfo() - if !ok { - return "dev" - } - for _, s := range info.Settings { - if s.Key == "vcs.revision" && s.Value != "" { - return s.Value - } - } - return "dev" -} - -// cors allows the browser origin to call the xrpc endpoints. auth is via -// bearer tokens, not cookies, so a wildcard origin is safe. -func (x *Xrpc) cors(next http.Handler) http.Handler { - origin := x.Config.Core.XrpcCorsOrigin - if origin == "" { - origin = "*" - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Max-Age", "86400") - if origin != "*" { - w.Header().Add("Vary", "Origin") - } - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(e) -} - -func (x *Xrpc) writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -func actorDid(r *http.Request) (string, bool) { - did, ok := r.Context().Value(ActorDid).(syntax.DID) - if !ok { - return "", false - } - return did.String(), true -} - -// stable client-facing errors; handlers log the real cause and return these -var ( - errInternal = xrpcErrorTag("InternalError", "internal server error") - errBadRequestBody = xrpcErrorTag("InvalidRequest", "invalid request body") - errUpstream = xrpcErrorTag("UpstreamError", "an upstream service failed") -) - -func xrpcErrorTag(tag, message string) xrpcerr.XrpcError { - return xrpcerr.NewXrpcError(xrpcerr.WithTag(tag), xrpcerr.WithMessage(message)) -} - -func badRequestError(message string) xrpcerr.XrpcError { - return xrpcErrorTag("InvalidRequest", message) -} - -func notFoundError(message string) xrpcerr.XrpcError { - return xrpcErrorTag("NotFound", message) -} - -func notImplementedError(message string) xrpcerr.XrpcError { - return xrpcErrorTag("MethodNotImplemented", message) -} diff --git a/appview/xrpc/xrpc_test.go b/appview/xrpc/xrpc_test.go deleted file mode 100644 index 33e034ba..00000000 --- a/appview/xrpc/xrpc_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package xrpc - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - "github.com/bluesky-social/indigo/atproto/atcrypto" - "github.com/bluesky-social/indigo/atproto/auth" - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/appview/config" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - "tangled.org/core/xrpc/serviceauth" -) - -const ( - testActor = "did:plc:tester" - testAudience = "did:web:test.example" -) - -// newTestXrpc builds an Xrpc backed by a fresh temp DB, with service auth wired -// to a mock directory holding testActor's key. It returns the router, the DB, -// and a function that signs a service-auth token for a given lexicon method. -func newTestXrpc(t *testing.T) (http.Handler, *db.DB, func(nsid string) string) { - t.Helper() - - d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("db.Make: %v", err) - } - t.Cleanup(func() { d.Close() }) - - priv, err := atcrypto.GeneratePrivateKeyP256() - if err != nil { - t.Fatalf("generate key: %v", err) - } - pub, err := priv.PublicKey() - if err != nil { - t.Fatalf("derive pubkey: %v", err) - } - - dir := identity.NewMockDirectory() - dir.Insert(identity.Identity{ - DID: syntax.DID(testActor), - Keys: map[string]identity.VerificationMethod{ - "atproto": {Type: "Multikey", PublicKeyMultibase: pub.Multibase()}, - }, - }) - - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - x := &Xrpc{ - DB: d, - Config: &config.Config{}, - Logger: logger, - ServiceAuth: serviceauth.NewServiceAuth(logger, dir, testAudience), - } - - sign := func(nsid string) string { - lxm := syntax.NSID(nsid) - token, err := auth.SignServiceAuth(syntax.DID(testActor), testAudience, time.Minute, &lxm, priv) - if err != nil { - t.Fatalf("sign service auth: %v", err) - } - return token - } - - return x.Router(), d, sign -} - -func TestHealth(t *testing.T) { - router, _, _ := newTestXrpc(t) - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_health", nil)) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var body map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) - } - if body["version"] == "" { - t.Fatalf("missing version in %s", rec.Body.String()) - } -} - -func TestServiceAuthRequired(t *testing.T) { - router, _, _ := newTestXrpc(t) - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil)) - - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want 403 without a service-auth token", rec.Code) - } -} - -func TestNotificationGetUnreadCount(t *testing.T) { - router, d, sign := newTestXrpc(t) - - nsid := "org.tangled.temp.notification.getUnreadCount" - call := func() int { - req := httptest.NewRequest(http.MethodGet, "/"+nsid, nil) - req.Header.Set("Authorization", "Bearer "+sign(nsid)) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - var out struct { - Count int `json:"count"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { - t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) - } - return out.Count - } - - if got := call(); got != 0 { - t.Fatalf("empty db count = %d, want 0", got) - } - - if err := db.CreateNotification(d, &models.Notification{ - RecipientDid: testActor, - ActorDid: "did:plc:someone", - Type: models.NotificationTypeRepoStarred, - Read: false, - }); err != nil { - t.Fatalf("CreateNotification: %v", err) - } - - if got := call(); got != 1 { - t.Fatalf("count after one unread = %d, want 1", got) - } -} - -func TestWrongLexiconTokenRejected(t *testing.T) { - router, _, sign := newTestXrpc(t) - - // a token minted for a different method must not authorize this call - req := httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil) - req.Header.Set("Authorization", "Bearer "+sign("org.tangled.temp.notification.listNotifications")) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want 403 for a token bound to a different method", rec.Code) - } -} diff --git a/bobbin/crates/edge-index/src/lib.rs b/bobbin/crates/edge-index/src/lib.rs index 657fd0c0..148287ae 100644 --- a/bobbin/crates/edge-index/src/lib.rs +++ b/bobbin/crates/edge-index/src/lib.rs @@ -527,7 +527,8 @@ impl EdgeStore { .map(AuthorId::from_spur); let spur = self.source_interner.get(self.source_key(source, author))?; let id = SourceId::from_spur(spur); - self.source_collections.read_sync(&id, |_, cols| cols.clone()) + self.source_collections + .read_sync(&id, |_, cols| cols.clone()) } fn add_locked(&self, edge: Edge) { @@ -1668,9 +1669,10 @@ mod tests { assert_eq!(store.source_collections_for(&source), None); // Set a filter - store.set_source_collections(&source, Some(vec![ - SmolStr::new_static("sh.tangled.repo.issue"), - ])); + store.set_source_collections( + &source, + Some(vec![SmolStr::new_static("sh.tangled.repo.issue")]), + ); let stored = store.source_collections_for(&source).unwrap(); assert_eq!(stored, vec![SmolStr::new_static("sh.tangled.repo.issue")]); @@ -1679,9 +1681,10 @@ mod tests { assert_eq!(store.source_collections_for(&source), None); // Set a different filter - store.set_source_collections(&source, Some(vec![ - SmolStr::new_static("sh.tangled.repo.pull"), - ])); + store.set_source_collections( + &source, + Some(vec![SmolStr::new_static("sh.tangled.repo.pull")]), + ); let stored = store.source_collections_for(&source).unwrap(); assert_eq!(stored, vec![SmolStr::new_static("sh.tangled.repo.pull")]); @@ -1701,9 +1704,10 @@ mod tests { sort_micros: 1, }; store.add(edge); - store.set_source_collections(&source, Some(vec![ - SmolStr::new_static("sh.tangled.repo.issue"), - ])); + store.set_source_collections( + &source, + Some(vec![SmolStr::new_static("sh.tangled.repo.issue")]), + ); assert!(store.source_collections_for(&source).is_some()); store.remove_source(&source); diff --git a/bobbin/crates/types/src/edges.rs b/bobbin/crates/types/src/edges.rs index 001d4f93..7baba0ce 100644 --- a/bobbin/crates/types/src/edges.rs +++ b/bobbin/crates/types/src/edges.rs @@ -686,7 +686,8 @@ mod tests { "createdAt": "2026-05-01T00:00:00Z", "subject": { "$type": "sh.tangled.feed.star#repo", "did": "did:plc:abalone" }, }), - ).unwrap(); + ) + .unwrap(); assert_eq!(star.subscription_collections(), None); // Unrestricted subscription (no collections field) → Some(None) @@ -697,7 +698,8 @@ mod tests { "createdAt": "2026-05-01T00:00:00Z", "subject": { "$type": "sh.tangled.feed.subscription#repo", "did": "did:plc:repo" }, }), - ).unwrap(); + ) + .unwrap(); assert_eq!(sub_none.subscription_collections(), Some(None)); // Filtered subscription → Some(Some(vec)) @@ -709,9 +711,13 @@ mod tests { "subject": { "$type": "sh.tangled.feed.subscription#repo", "did": "did:plc:repo" }, "collections": ["sh.tangled.repo.issue"], }), - ).unwrap(); + ) + .unwrap(); let cols = sub_filtered.subscription_collections(); - assert_eq!(cols, Some(Some(vec![SmolStr::new_static("sh.tangled.repo.issue")]))); + assert_eq!( + cols, + Some(Some(vec![SmolStr::new_static("sh.tangled.repo.issue")])) + ); } #[test] diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index 3c12f4d8..8e7144e4 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -2533,10 +2533,7 @@ async fn list_recipients( )); }; - let key = EdgeKey::new( - nsid_static("sh.tangled.feed.subscription"), - subject_ref, - ); + let key = EdgeKey::new(nsid_static("sh.tangled.feed.subscription"), subject_ref); let sources = state.edges.sources_for(&key); // NOTE: collection filtering intentionally disabled. diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs index e0e59c1b..86f58188 100644 --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -2224,10 +2224,8 @@ async fn list_recipients_entity_subject_returns_subscriber_dids() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body: Value = serde_json::from_slice( - &to_bytes(resp.into_body(), 1 << 20).await.unwrap(), - ) - .unwrap(); + let body: Value = + serde_json::from_slice(&to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap(); assert_eq!(body["dids"], json!(["did:plc:bob"])); } @@ -2244,17 +2242,17 @@ async fn list_recipients_repo_subject_returns_repo_subscribers() { let resp = router(h.state.clone()) .oneshot( Request::builder() - .uri("/xrpc/org.tangled.temp.notification.listRecipients?subject=did:plc:targetrepo") + .uri( + "/xrpc/org.tangled.temp.notification.listRecipients?subject=did:plc:targetrepo", + ) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body: Value = serde_json::from_slice( - &to_bytes(resp.into_body(), 1 << 20).await.unwrap(), - ) - .unwrap(); + let body: Value = + serde_json::from_slice(&to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap(); assert_eq!(body["dids"], json!(["did:plc:watcher"])); } @@ -2271,9 +2269,7 @@ async fn list_recipients_empty_for_unknown_subject() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body: Value = serde_json::from_slice( - &to_bytes(resp.into_body(), 1 << 20).await.unwrap(), - ) - .unwrap(); + let body: Value = + serde_json::from_slice(&to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap(); assert_eq!(body["dids"], json!([])); }