From ea5faaf11069c136bd76d9ec40ca251f8c708c46 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 21 Jul 2026 11:18:49 +0300 Subject: [PATCH] appview/xrpc: handlers for "private" xrpc service Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 5 + appview/db/webhooks.go | 52 +++ appview/notify/webhook/notifier.go | 23 +- appview/notify/webhook/notifier_test.go | 2 +- appview/settings/settings.go | 3 +- appview/signup/signup.go | 45 +-- appview/state/router.go | 25 ++ appview/state/state.go | 2 +- .../userutil}/moderation.go | 4 +- appview/state/userutil/userutil.go | 47 +++ appview/xrpc/account.go | 173 ++++++++ appview/xrpc/focus.go | 119 ++++++ appview/xrpc/notifications.go | 279 +++++++++++++ appview/xrpc/search.go | 157 +++++++ appview/xrpc/signup.go | 306 ++++++++++++++ appview/xrpc/sites.go | 329 +++++++++++++++ appview/xrpc/webhooks.go | 382 ++++++++++++++++++ appview/xrpc/xrpc.go | 184 +++++++++ appview/xrpc/xrpc_test.go | 158 ++++++++ hostutil/safedial.go | 84 ++++ 20 files changed, 2326 insertions(+), 53 deletions(-) rename appview/{settings => state/userutil}/moderation.go (99%) create mode 100644 appview/xrpc/account.go create mode 100644 appview/xrpc/focus.go create mode 100644 appview/xrpc/notifications.go create mode 100644 appview/xrpc/search.go create mode 100644 appview/xrpc/signup.go create mode 100644 appview/xrpc/sites.go create mode 100644 appview/xrpc/webhooks.go create mode 100644 appview/xrpc/xrpc.go create mode 100644 appview/xrpc/xrpc_test.go create mode 100644 hostutil/safedial.go diff --git a/appview/config/config.go b/appview/config/config.go index a8f24463..d8db366a 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -23,6 +23,11 @@ type CoreConfig struct { Dev bool `env:"DEV, default=false"` DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` + // 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/db/webhooks.go b/appview/db/webhooks.go index 6ec9ea8d..e7b6b121 100644 --- a/appview/db/webhooks.go +++ b/appview/db/webhooks.go @@ -283,6 +283,58 @@ func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.Webhoo return deliveries, nil } +// GetWebhookDelivery returns a single delivery by its delivery_id (a uuid). +func GetWebhookDelivery(e Execer, deliveryId string) (*models.WebhookDelivery, error) { + var d models.WebhookDelivery + var createdAt string + var success int + var responseCode sql.NullInt64 + var responseBody sql.NullString + + err := e.QueryRow(` + select + id, + webhook_id, + event, + delivery_id, + url, + request_body, + response_code, + response_body, + success, + created_at + from webhook_deliveries + where delivery_id = ? + `, deliveryId).Scan( + &d.Id, + &d.WebhookId, + &d.Event, + &d.DeliveryId, + &d.Url, + &d.RequestBody, + &responseCode, + &responseBody, + &success, + &createdAt, + ) + if err != nil { + return nil, err + } + + d.Success = success == 1 + if responseCode.Valid { + d.ResponseCode = int(responseCode.Int64) + } + if responseBody.Valid { + d.ResponseBody = responseBody.String + } + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + d.CreatedAt = t + } + + return &d, nil +} + // GetWebhooksForRepo is a convenience function to get all webhooks for a repository func GetWebhooksForRepo(e Execer, repoDid string) ([]models.Webhook, error) { return GetWebhooks(e, orm.FilterEq("repo_did", repoDid)) diff --git a/appview/notify/webhook/notifier.go b/appview/notify/webhook/notifier.go index bf0ebe9c..21732a79 100644 --- a/appview/notify/webhook/notifier.go +++ b/appview/notify/webhook/notifier.go @@ -19,6 +19,7 @@ import ( "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" + "tangled.org/core/hostutil" "tangled.org/core/log" "tangled.org/core/orm" ) @@ -31,14 +32,14 @@ type Notifier struct { client *http.Client } -func NewNotifier(database *db.DB, baseUrl string) *Notifier { +func NewNotifier(database *db.DB, baseUrl string, dev bool) *Notifier { return &Notifier{ db: database, baseUrl: baseUrl, logger: log.New("webhook-notifier"), - client: &http.Client{ - Timeout: 30 * time.Second, - }, + // user-supplied webhook URLs are untrusted: block internal address + // ranges and don't follow redirects to guard against SSRF. + client: hostutil.SafeClient(dev, 30*time.Second), } } @@ -198,6 +199,20 @@ func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull } } +// Redeliver re-sends a stored delivery via the live send-and-record path, +// signing with the webhook's current secret. +func (w *Notifier) Redeliver(ctx context.Context, webhook models.Webhook, prev models.WebhookDelivery) { + // recover the repo full name (X-Tangled-Repo header) from the stored payload + var meta struct { + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } + _ = json.Unmarshal([]byte(prev.RequestBody), &meta) + + w.sendWebhook(ctx, webhook, prev.Event, meta.Repository.FullName, "Tangled-Hook/retry", []byte(prev.RequestBody)) +} + func (w *Notifier) activeWebhooksForEvent(repoDid string, event models.WebhookEvent) ([]models.Webhook, error) { webhooks, err := db.GetActiveWebhooksForRepo(w.db, repoDid) if err != nil { diff --git a/appview/notify/webhook/notifier_test.go b/appview/notify/webhook/notifier_test.go index 129fbfa5..aa788ec0 100644 --- a/appview/notify/webhook/notifier_test.go +++ b/appview/notify/webhook/notifier_test.go @@ -224,7 +224,7 @@ func newNotifierTestEnv(t *testing.T, events []string) *notifierTestEnv { } return ¬ifierTestEnv{ - notifier: NewNotifier(d, "https://tangled.org"), + notifier: NewNotifier(d, "https://tangled.org", true), webhook: webhook, db: d, received: received, diff --git a/appview/settings/settings.go b/appview/settings/settings.go index 8a1a25e3..26c94849 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -24,6 +24,7 @@ import ( "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/sites" + "tangled.org/core/appview/state/userutil" "tangled.org/core/idresolver" "tangled.org/core/tid" @@ -145,7 +146,7 @@ func (s *Settings) claimSitesDomain(w http.ResponseWriter, r *http.Request) { return } - if subdomainHasSlur(subdomain) { + if userutil.HasSlur(subdomain) { s.Pages.Notice(w, "settings-sites-error", "That subdomain is not allowed.") return } diff --git a/appview/signup/signup.go b/appview/signup/signup.go index 3eb0ac90..5b4b584a 100644 --- a/appview/signup/signup.go +++ b/appview/signup/signup.go @@ -1,7 +1,6 @@ package signup import ( - "bufio" "context" "encoding/json" "errors" @@ -9,7 +8,6 @@ import ( "log/slog" "net/http" "net/url" - "os" "strings" "github.com/go-chi/chi/v5" @@ -45,7 +43,7 @@ func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idr } } - disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) + disallowedNicknames := userutil.LoadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) return &Signup{ config: cfg, @@ -59,47 +57,6 @@ func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idr } } -func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { - disallowed := make(map[string]bool) - - if filepath == "" { - logger.Warn("no disallowed nicknames file configured") - return disallowed - } - - file, err := os.Open(filepath) - if err != nil { - logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) - return disallowed - } - defer file.Close() - - scanner := bufio.NewScanner(file) - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue // skip empty lines and comments - } - - nickname := strings.ToLower(line) - if userutil.IsValidSubdomain(nickname) { - disallowed[nickname] = true - } else { - logger.Warn("invalid nickname format in disallowed nicknames file", - "file", filepath, "line", lineNum, "nickname", nickname) - } - } - - if err := scanner.Err(); err != nil { - logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) - } - - logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) - return disallowed -} - // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) func (s *Signup) isNicknameAllowed(nickname string) bool { return !s.disallowedNicknames[strings.ToLower(nickname)] diff --git a/appview/state/router.go b/appview/state/router.go index c87d4b65..a45e2eb1 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -25,10 +25,13 @@ import ( "tangled.org/core/appview/signup" "tangled.org/core/appview/spindles" "tangled.org/core/appview/state/userutil" + whnotify "tangled.org/core/appview/notify/webhook" 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 { @@ -296,6 +299,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/focus", s.FocusRouter(mw)) r.Mount("/signup", s.SignupRouter()) + r.Mount("/xrpc", s.XrpcRouter()) r.Mount("/", s.oauth.Router()) r.Get("/terms", s.TermsOfService) @@ -479,3 +483,24 @@ 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/state/state.go b/appview/state/state.go index ed72bed5..dad7623b 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -178,7 +178,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { } notifiers = append(notifiers, indexer) - notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl())) + notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl(), config.Core.Dev)) notifier := notify.NewMergedNotifier(notifiers) notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) diff --git a/appview/settings/moderation.go b/appview/state/userutil/moderation.go similarity index 99% rename from appview/settings/moderation.go rename to appview/state/userutil/moderation.go index da3df2b0..5a1b865c 100644 --- a/appview/settings/moderation.go +++ b/appview/state/userutil/moderation.go @@ -1,4 +1,4 @@ -package settings +package userutil import ( "regexp" @@ -98,7 +98,7 @@ func foldToASCII(s string) string { // 3. trailing digits stripped // 4. separators stripped + trailing digits stripped // 5. leetspeak normalised variants of all of the above -func subdomainHasSlur(subdomain string) bool { +func HasSlur(subdomain string) bool { lower := strings.ToLower(subdomain) normalized := strings.NewReplacer(".", "", "-", "", "_", "").Replace(lower) stripped := stripTrailingDigits(lower) diff --git a/appview/state/userutil/userutil.go b/appview/state/userutil/userutil.go index dfc69dcc..31044298 100644 --- a/appview/state/userutil/userutil.go +++ b/appview/state/userutil/userutil.go @@ -1,6 +1,9 @@ package userutil import ( + "bufio" + "log/slog" + "os" "regexp" "strings" ) @@ -57,3 +60,47 @@ var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) func IsValidSubdomain(name string) bool { return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) } + +// LoadDisallowedNicknames reads a newline-separated list of reserved nicknames +// from filepath (blank lines and #-comments ignored). An empty filepath or a +// read error yields an empty set. Invalid entries are logged and skipped. +func LoadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { + disallowed := make(map[string]bool) + + if filepath == "" { + logger.Warn("no disallowed nicknames file configured") + return disallowed + } + + file, err := os.Open(filepath) + if err != nil { + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) + return disallowed + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + nickname := strings.ToLower(line) + if IsValidSubdomain(nickname) { + disallowed[nickname] = true + } else { + logger.Warn("invalid nickname format in disallowed nicknames file", + "file", filepath, "line", lineNum, "nickname", nickname) + } + } + + if err := scanner.Err(); err != nil { + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) + } + + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) + return disallowed +} diff --git a/appview/xrpc/account.go b/appview/xrpc/account.go new file mode 100644 index 00000000..8aef97ee --- /dev/null +++ b/appview/xrpc/account.go @@ -0,0 +1,173 @@ +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 new file mode 100644 index 00000000..bde72db9 --- /dev/null +++ b/appview/xrpc/focus.go @@ -0,0 +1,119 @@ +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 new file mode 100644 index 00000000..fa7604d6 --- /dev/null +++ b/appview/xrpc/notifications.go @@ -0,0 +1,279 @@ +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 new file mode 100644 index 00000000..a9cfe288 --- /dev/null +++ b/appview/xrpc/search.go @@ -0,0 +1,157 @@ +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 new file mode 100644 index 00000000..07c39635 --- /dev/null +++ b/appview/xrpc/signup.go @@ -0,0 +1,306 @@ +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 new file mode 100644 index 00000000..15210f39 --- /dev/null +++ b/appview/xrpc/sites.go @@ -0,0 +1,329 @@ +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 new file mode 100644 index 00000000..50b9b40b --- /dev/null +++ b/appview/xrpc/webhooks.go @@ -0,0 +1,382 @@ +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 new file mode 100644 index 00000000..8c358e31 --- /dev/null +++ b/appview/xrpc/xrpc.go @@ -0,0 +1,184 @@ +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 new file mode 100644 index 00000000..33e034ba --- /dev/null +++ b/appview/xrpc/xrpc_test.go @@ -0,0 +1,158 @@ +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/hostutil/safedial.go b/hostutil/safedial.go new file mode 100644 index 00000000..f11685b8 --- /dev/null +++ b/hostutil/safedial.go @@ -0,0 +1,84 @@ +package hostutil + +import ( + "fmt" + "net" + "net/http" + "net/url" + "syscall" + "time" +) + +// isBlockedIP reports whether ip is loopback, private, link-local (incl. the +// 169.254.169.254 metadata endpoint), multicast, or unspecified. +func isBlockedIP(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsMulticast() || + ip.IsUnspecified() +} + +// safeDialer rejects dials to non-public addresses. the Control hook runs after +// dns resolution, so it also covers rebinding and redirects. disabled in dev. +func safeDialer(dev bool) *net.Dialer { + d := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + if dev { + return d + } + d.Control = func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("invalid dial address %q: %w", address, err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("dial address %q did not resolve to an IP", address) + } + if isBlockedIP(ip) { + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) + } + return nil + } + return d +} + +// ValidateExternalURL checks raw is a well-formed http(s) url and rejects +// ip-literal hosts in blocked ranges; dns hosts are re-checked at dial time. +func ValidateExternalURL(raw string, dev bool) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL must use http or https") + } + if u.Hostname() == "" { + return fmt.Errorf("URL must include a host") + } + if dev { + return nil + } + if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedIP(ip) { + return fmt.Errorf("URL host is a reserved or private address") + } + return nil +} + +// SafeClient returns an http.Client for fetching untrusted urls (e.g. +// webhooks): it blocks internal address ranges and won't follow redirects. +func SafeClient(dev bool, timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + DialContext: safeDialer(dev).DialContext, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} -- 2.51.2