This repository has no description
1package xrpc
2
3import (
4 "encoding/json"
5 "log/slog"
6 "net/http"
7 "runtime/debug"
8
9 "github.com/bluesky-social/indigo/atproto/syntax"
10 "github.com/go-chi/chi/v5"
11 "tangled.org/core/api/tangled"
12 "tangled.org/core/appview/cloudflare"
13 "tangled.org/core/appview/codesearch"
14 "tangled.org/core/appview/config"
15 "tangled.org/core/appview/db"
16 whnotify "tangled.org/core/appview/notify/webhook"
17 "tangled.org/core/idresolver"
18 xrpcerr "tangled.org/core/xrpc/errors"
19 "tangled.org/core/xrpc/serviceauth"
20)
21
22const ActorDid = serviceauth.ActorDid
23
24type Xrpc struct {
25 DB *db.DB
26 Config *config.Config
27 Logger *slog.Logger
28 ServiceAuth *serviceauth.ServiceAuth
29 IdResolver *idresolver.Resolver
30 Cloudflare *cloudflare.Client
31 CodeSearch *codesearch.CodeSearch
32 Webhooks *whnotify.Notifier
33
34 // reserved usernames rejected at signup completion
35 DisallowedNicknames map[string]bool
36}
37
38func (x *Xrpc) Router() http.Handler {
39 r := chi.NewRouter()
40
41 r.Use(x.cors)
42
43 // health check, atproto _health convention
44 r.Get("/_health", x.health)
45
46 // open endpoints: signup happens pre-identity, so no service auth
47 r.Post("/"+tangled.TempAccountBeginSignupNSID, x.AccountBeginSignup)
48 r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup)
49
50 // authenticated endpoints
51 r.Group(func(r chi.Router) {
52 r.Use(x.ServiceAuth.VerifyServiceAuth)
53
54 // code search is gated on login, matching the appview ui
55 r.Get("/"+tangled.TempSearchSearchCodeNSID, x.SearchSearchCode)
56
57 // notifications
58 r.Get("/"+tangled.TempNotificationListNotificationsNSID, x.NotificationList)
59 r.Get("/"+tangled.TempNotificationGetUnreadCountNSID, x.NotificationGetUnreadCount)
60 r.Post("/"+tangled.TempNotificationUpdateSeenNSID, x.NotificationUpdateSeen)
61 r.Post("/"+tangled.TempNotificationMarkAllReadNSID, x.NotificationMarkAllRead)
62 r.Post("/"+tangled.TempNotificationDeleteNotificationNSID, x.NotificationDelete)
63 r.Get("/"+tangled.TempNotificationGetPreferencesNSID, x.NotificationGetPreferences)
64 r.Post("/"+tangled.TempNotificationUpdatePreferencesNSID, x.NotificationUpdatePreferences)
65
66 // focus mode
67 r.Post("/"+tangled.TempFocusBeginSessionNSID, x.FocusBegin)
68 r.Post("/"+tangled.TempFocusNextItemNSID, x.FocusNext)
69 r.Post("/"+tangled.TempFocusEndSessionNSID, x.FocusEnd)
70
71 // account management
72 r.Get("/"+tangled.TempAccountListEmailsNSID, x.AccountListEmails)
73 r.Post("/"+tangled.TempAccountDeleteEmailNSID, x.AccountDeleteEmail)
74 r.Post("/"+tangled.TempAccountSetPrimaryEmailNSID, x.AccountSetPrimaryEmail)
75 r.Post("/"+tangled.TempAccountSubscribeNewsletterNSID, x.AccountSubscribeNewsletter)
76 r.Post("/"+tangled.TempAccountDismissNewsletterNSID, x.AccountDismissNewsletter)
77
78 // webhooks
79 r.Get("/"+tangled.TempRepoListWebhooksNSID, x.WebhookList)
80 r.Post("/"+tangled.TempRepoCreateWebhookNSID, x.WebhookCreate)
81 r.Post("/"+tangled.TempRepoUpdateWebhookNSID, x.WebhookUpdate)
82 r.Post("/"+tangled.TempRepoDeleteWebhookNSID, x.WebhookDelete)
83 r.Post("/"+tangled.TempRepoToggleWebhookNSID, x.WebhookToggle)
84 r.Get("/"+tangled.TempRepoListWebhookDeliveriesNSID, x.WebhookListDeliveries)
85 r.Post("/"+tangled.TempRepoRetryWebhookDeliveryNSID, x.WebhookRetryDelivery)
86
87 // sites
88 r.Get("/"+tangled.TempSiteGetDomainClaimNSID, x.SiteGetDomainClaim)
89 r.Post("/"+tangled.TempSiteClaimDomainNSID, x.SiteClaimDomain)
90 r.Post("/"+tangled.TempSiteReleaseDomainNSID, x.SiteReleaseDomain)
91 r.Get("/"+tangled.TempRepoGetSiteConfigNSID, x.SiteGetRepoSiteConfig)
92 r.Post("/"+tangled.TempRepoUpdateSiteConfigNSID, x.SiteUpdateRepoSiteConfig)
93 r.Post("/"+tangled.TempRepoDisableSiteNSID, x.SiteDisableRepoSite)
94 })
95
96 return r
97}
98
99// timeFormat is the datetime format used across lexicon output fields
100const timeFormat = "2006-01-02T15:04:05.000Z"
101
102// health responds to /xrpc/_health with the running version
103func (x *Xrpc) health(w http.ResponseWriter, r *http.Request) {
104 x.writeJSON(w, map[string]string{"version": serviceVersion()})
105}
106
107// serviceVersion returns the build's vcs revision, or "dev"
108func serviceVersion() string {
109 info, ok := debug.ReadBuildInfo()
110 if !ok {
111 return "dev"
112 }
113 for _, s := range info.Settings {
114 if s.Key == "vcs.revision" && s.Value != "" {
115 return s.Value
116 }
117 }
118 return "dev"
119}
120
121// cors allows the browser origin to call the xrpc endpoints. auth is via
122// bearer tokens, not cookies, so a wildcard origin is safe.
123func (x *Xrpc) cors(next http.Handler) http.Handler {
124 origin := x.Config.Core.XrpcCorsOrigin
125 if origin == "" {
126 origin = "*"
127 }
128 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129 w.Header().Set("Access-Control-Allow-Origin", origin)
130 w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
131 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
132 w.Header().Set("Access-Control-Max-Age", "86400")
133 if origin != "*" {
134 w.Header().Add("Vary", "Origin")
135 }
136 if r.Method == http.MethodOptions {
137 w.WriteHeader(http.StatusNoContent)
138 return
139 }
140 next.ServeHTTP(w, r)
141 })
142}
143
144func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
145 w.Header().Set("Content-Type", "application/json")
146 w.WriteHeader(status)
147 json.NewEncoder(w).Encode(e)
148}
149
150func (x *Xrpc) writeJSON(w http.ResponseWriter, v any) {
151 w.Header().Set("Content-Type", "application/json")
152 json.NewEncoder(w).Encode(v)
153}
154
155func actorDid(r *http.Request) (string, bool) {
156 did, ok := r.Context().Value(ActorDid).(syntax.DID)
157 if !ok {
158 return "", false
159 }
160 return did.String(), true
161}
162
163// stable client-facing errors; handlers log the real cause and return these
164var (
165 errInternal = xrpcErrorTag("InternalError", "Internal server error.")
166 errBadRequestBody = xrpcErrorTag("InvalidRequest", "Invalid request body.")
167 errUpstream = xrpcErrorTag("UpstreamError", "An upstream service failed.")
168)
169
170func xrpcErrorTag(tag, message string) xrpcerr.XrpcError {
171 return xrpcerr.NewXrpcError(xrpcerr.WithTag(tag), xrpcerr.WithMessage(message))
172}
173
174func badRequestError(message string) xrpcerr.XrpcError {
175 return xrpcErrorTag("InvalidRequest", message)
176}
177
178func notFoundError(message string) xrpcerr.XrpcError {
179 return xrpcErrorTag("NotFound", message)
180}
181
182func notImplementedError(message string) xrpcerr.XrpcError {
183 return xrpcErrorTag("MethodNotImplemented", message)
184}