This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/xrpc: handlers for "private" xrpc service

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

author
Anirudh Oppiliappan
date (Jul 27, 2026, 5:27 PM +0300) commit ea5faaf1 parent 454f5366 change-id tpuovsmw
+2326 -53
+5
appview/config/config.go
··· 23 23 Dev bool `env:"DEV, default=false"` 24 24 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 25 25 26 + // origin allowed to call the xrpc endpoints from the browser (the svelte 27 + // frontend). empty allows any origin, which is safe here since xrpc uses 28 + // bearer service-auth tokens rather than cookies. 29 + XrpcCorsOrigin string `env:"XRPC_CORS_ORIGIN"` 30 + 26 31 // temporarily, to add users to default knot and spindle 27 32 AppPassword string `env:"APP_PASSWORD"` 28 33
+52
appview/db/webhooks.go
··· 283 283 return deliveries, nil 284 284 } 285 285 286 + // GetWebhookDelivery returns a single delivery by its delivery_id (a uuid). 287 + func GetWebhookDelivery(e Execer, deliveryId string) (*models.WebhookDelivery, error) { 288 + var d models.WebhookDelivery 289 + var createdAt string 290 + var success int 291 + var responseCode sql.NullInt64 292 + var responseBody sql.NullString 293 + 294 + err := e.QueryRow(` 295 + select 296 + id, 297 + webhook_id, 298 + event, 299 + delivery_id, 300 + url, 301 + request_body, 302 + response_code, 303 + response_body, 304 + success, 305 + created_at 306 + from webhook_deliveries 307 + where delivery_id = ? 308 + `, deliveryId).Scan( 309 + &d.Id, 310 + &d.WebhookId, 311 + &d.Event, 312 + &d.DeliveryId, 313 + &d.Url, 314 + &d.RequestBody, 315 + &responseCode, 316 + &responseBody, 317 + &success, 318 + &createdAt, 319 + ) 320 + if err != nil { 321 + return nil, err 322 + } 323 + 324 + d.Success = success == 1 325 + if responseCode.Valid { 326 + d.ResponseCode = int(responseCode.Int64) 327 + } 328 + if responseBody.Valid { 329 + d.ResponseBody = responseBody.String 330 + } 331 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 332 + d.CreatedAt = t 333 + } 334 + 335 + return &d, nil 336 + } 337 + 286 338 // GetWebhooksForRepo is a convenience function to get all webhooks for a repository 287 339 func GetWebhooksForRepo(e Execer, repoDid string) ([]models.Webhook, error) { 288 340 return GetWebhooks(e, orm.FilterEq("repo_did", repoDid))
+19 -4
appview/notify/webhook/notifier.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/notify" 22 + "tangled.org/core/hostutil" 22 23 "tangled.org/core/log" 23 24 "tangled.org/core/orm" 24 25 ) ··· 31 32 client *http.Client 32 33 } 33 34 34 - func NewNotifier(database *db.DB, baseUrl string) *Notifier { 35 + func NewNotifier(database *db.DB, baseUrl string, dev bool) *Notifier { 35 36 return &Notifier{ 36 37 db: database, 37 38 baseUrl: baseUrl, 38 39 logger: log.New("webhook-notifier"), 39 - client: &http.Client{ 40 - Timeout: 30 * time.Second, 41 - }, 40 + // user-supplied webhook URLs are untrusted: block internal address 41 + // ranges and don't follow redirects to guard against SSRF. 42 + client: hostutil.SafeClient(dev, 30*time.Second), 42 43 } 43 44 } 44 45 ··· 196 197 Repository: buildWebhookRepository(repo), 197 198 Sender: models.WebhookUser{Did: sender}, 198 199 } 200 + } 201 + 202 + // Redeliver re-sends a stored delivery via the live send-and-record path, 203 + // signing with the webhook's current secret. 204 + func (w *Notifier) Redeliver(ctx context.Context, webhook models.Webhook, prev models.WebhookDelivery) { 205 + // recover the repo full name (X-Tangled-Repo header) from the stored payload 206 + var meta struct { 207 + Repository struct { 208 + FullName string `json:"full_name"` 209 + } `json:"repository"` 210 + } 211 + _ = json.Unmarshal([]byte(prev.RequestBody), &meta) 212 + 213 + w.sendWebhook(ctx, webhook, prev.Event, meta.Repository.FullName, "Tangled-Hook/retry", []byte(prev.RequestBody)) 199 214 } 200 215 201 216 func (w *Notifier) activeWebhooksForEvent(repoDid string, event models.WebhookEvent) ([]models.Webhook, error) {
+1 -1
appview/notify/webhook/notifier_test.go
··· 224 224 } 225 225 226 226 return &notifierTestEnv{ 227 - notifier: NewNotifier(d, "https://tangled.org"), 227 + notifier: NewNotifier(d, "https://tangled.org", true), 228 228 webhook: webhook, 229 229 db: d, 230 230 received: received,
+2 -2
appview/settings/moderation.go appview/state/userutil/moderation.go
··· 1 - package settings 1 + package userutil 2 2 3 3 import ( 4 4 "regexp" ··· 98 98 // 3. trailing digits stripped 99 99 // 4. separators stripped + trailing digits stripped 100 100 // 5. leetspeak normalised variants of all of the above 101 - func subdomainHasSlur(subdomain string) bool { 101 + func HasSlur(subdomain string) bool { 102 102 lower := strings.ToLower(subdomain) 103 103 normalized := strings.NewReplacer(".", "", "-", "", "_", "").Replace(lower) 104 104 stripped := stripTrailingDigits(lower)
+2 -1
appview/settings/settings.go
··· 24 24 "tangled.org/core/appview/oauth" 25 25 "tangled.org/core/appview/pages" 26 26 "tangled.org/core/appview/sites" 27 + "tangled.org/core/appview/state/userutil" 27 28 "tangled.org/core/idresolver" 28 29 "tangled.org/core/tid" 29 30 ··· 145 146 return 146 147 } 147 148 148 - if subdomainHasSlur(subdomain) { 149 + if userutil.HasSlur(subdomain) { 149 150 s.Pages.Notice(w, "settings-sites-error", "That subdomain is not allowed.") 150 151 return 151 152 }
+1 -44
appview/signup/signup.go
··· 1 1 package signup 2 2 3 3 import ( 4 - "bufio" 5 4 "context" 6 5 "encoding/json" 7 6 "errors" ··· 9 8 "log/slog" 10 9 "net/http" 11 10 "net/url" 12 - "os" 13 11 "strings" 14 12 15 13 "github.com/go-chi/chi/v5" ··· 45 43 } 46 44 } 47 45 48 - disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + disallowedNicknames := userutil.LoadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 49 47 50 48 return &Signup{ 51 49 config: cfg, ··· 57 55 l: l, 58 56 disallowedNicknames: disallowedNicknames, 59 57 } 60 - } 61 - 62 - func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 63 - disallowed := make(map[string]bool) 64 - 65 - if filepath == "" { 66 - logger.Warn("no disallowed nicknames file configured") 67 - return disallowed 68 - } 69 - 70 - file, err := os.Open(filepath) 71 - if err != nil { 72 - logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 73 - return disallowed 74 - } 75 - defer file.Close() 76 - 77 - scanner := bufio.NewScanner(file) 78 - lineNum := 0 79 - for scanner.Scan() { 80 - lineNum++ 81 - line := strings.TrimSpace(scanner.Text()) 82 - if line == "" || strings.HasPrefix(line, "#") { 83 - continue // skip empty lines and comments 84 - } 85 - 86 - nickname := strings.ToLower(line) 87 - if userutil.IsValidSubdomain(nickname) { 88 - disallowed[nickname] = true 89 - } else { 90 - logger.Warn("invalid nickname format in disallowed nicknames file", 91 - "file", filepath, "line", lineNum, "nickname", nickname) 92 - } 93 - } 94 - 95 - if err := scanner.Err(); err != nil { 96 - logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 97 - } 98 - 99 - logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 100 - return disallowed 101 58 } 102 59 103 60 // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
+25
appview/state/router.go
··· 25 25 "tangled.org/core/appview/signup" 26 26 "tangled.org/core/appview/spindles" 27 27 "tangled.org/core/appview/state/userutil" 28 + whnotify "tangled.org/core/appview/notify/webhook" 28 29 avstrings "tangled.org/core/appview/strings" 29 30 avtimeline "tangled.org/core/appview/timeline" 31 + avxrpc "tangled.org/core/appview/xrpc" 30 32 "tangled.org/core/blog" 31 33 "tangled.org/core/log" 34 + "tangled.org/core/xrpc/serviceauth" 32 35 ) 33 36 34 37 func (s *State) Router() http.Handler { ··· 296 299 r.Mount("/focus", s.FocusRouter(mw)) 297 300 298 301 r.Mount("/signup", s.SignupRouter()) 302 + r.Mount("/xrpc", s.XrpcRouter()) 299 303 r.Mount("/", s.oauth.Router()) 300 304 301 305 r.Get("/terms", s.TermsOfService) ··· 479 483 sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 480 484 return sig.Router() 481 485 } 486 + 487 + // XrpcRouter serves the org.tangled.* methods owned by the go service; callers 488 + // authenticate with atproto service auth, audience did:web:<appview host> 489 + func (s *State) XrpcRouter() http.Handler { 490 + audience := serviceauth.DidWeb(s.config.Core.AppviewHost).String() 491 + sa := serviceauth.NewServiceAuth(s.logger, s.idResolver.Directory(), audience) 492 + 493 + xlogger := log.SubLogger(s.logger, "xrpc") 494 + x := &avxrpc.Xrpc{ 495 + DB: s.db, 496 + Config: s.config, 497 + Logger: xlogger, 498 + ServiceAuth: sa, 499 + IdResolver: s.idResolver, 500 + Cloudflare: s.cfClient, 501 + CodeSearch: s.codesearch, 502 + Webhooks: whnotify.NewNotifier(s.db, s.config.Core.BaseUrl(), s.config.Core.Dev), 503 + DisallowedNicknames: userutil.LoadDisallowedNicknames(s.config.Core.DisallowedNicknamesFile, xlogger), 504 + } 505 + return x.Router() 506 + }
+1 -1
appview/state/state.go
··· 178 178 } 179 179 notifiers = append(notifiers, indexer) 180 180 181 - notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl())) 181 + notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl(), config.Core.Dev)) 182 182 183 183 notifier := notify.NewMergedNotifier(notifiers) 184 184 notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify"))
+47
appview/state/userutil/userutil.go
··· 1 1 package userutil 2 2 3 3 import ( 4 + "bufio" 5 + "log/slog" 6 + "os" 4 7 "regexp" 5 8 "strings" 6 9 ) ··· 57 60 func IsValidSubdomain(name string) bool { 58 61 return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 62 } 63 + 64 + // LoadDisallowedNicknames reads a newline-separated list of reserved nicknames 65 + // from filepath (blank lines and #-comments ignored). An empty filepath or a 66 + // read error yields an empty set. Invalid entries are logged and skipped. 67 + func LoadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 68 + disallowed := make(map[string]bool) 69 + 70 + if filepath == "" { 71 + logger.Warn("no disallowed nicknames file configured") 72 + return disallowed 73 + } 74 + 75 + file, err := os.Open(filepath) 76 + if err != nil { 77 + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 78 + return disallowed 79 + } 80 + defer file.Close() 81 + 82 + scanner := bufio.NewScanner(file) 83 + lineNum := 0 84 + for scanner.Scan() { 85 + lineNum++ 86 + line := strings.TrimSpace(scanner.Text()) 87 + if line == "" || strings.HasPrefix(line, "#") { 88 + continue 89 + } 90 + 91 + nickname := strings.ToLower(line) 92 + if IsValidSubdomain(nickname) { 93 + disallowed[nickname] = true 94 + } else { 95 + logger.Warn("invalid nickname format in disallowed nicknames file", 96 + "file", filepath, "line", lineNum, "nickname", nickname) 97 + } 98 + } 99 + 100 + if err := scanner.Err(); err != nil { 101 + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 102 + } 103 + 104 + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 105 + return disallowed 106 + }
+173
appview/xrpc/account.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "errors" 7 + "net/http" 8 + "strings" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/email" 13 + xrpcerr "tangled.org/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) AccountListEmails(w http.ResponseWriter, r *http.Request) { 17 + l := x.Logger.With("handler", "AccountListEmails") 18 + 19 + did, ok := actorDid(r) 20 + if !ok { 21 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 22 + return 23 + } 24 + 25 + emails, err := db.GetAllEmails(x.DB, did) 26 + if err != nil { 27 + l.Error("failed to get emails", "err", err) 28 + writeError(w, errInternal, http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + items := make([]*tangled.TempAccountListEmails_Email, 0, len(emails)) 33 + for _, e := range emails { 34 + items = append(items, &tangled.TempAccountListEmails_Email{ 35 + Address: e.Address, 36 + Verified: e.Verified, 37 + Primary: e.Primary, 38 + CreatedAt: e.CreatedAt.UTC().Format(timeFormat), 39 + }) 40 + } 41 + 42 + x.writeJSON(w, &tangled.TempAccountListEmails_Output{Emails: items}) 43 + } 44 + 45 + func (x *Xrpc) AccountDeleteEmail(w http.ResponseWriter, r *http.Request) { 46 + l := x.Logger.With("handler", "AccountDeleteEmail") 47 + 48 + did, ok := actorDid(r) 49 + if !ok { 50 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 51 + return 52 + } 53 + 54 + var input tangled.TempAccountDeleteEmail_Input 55 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 56 + writeError(w, errBadRequestBody, http.StatusBadRequest) 57 + return 58 + } 59 + addr := strings.TrimSpace(input.Email) 60 + 61 + existing, err := db.GetEmail(x.DB, did, addr) 62 + if err != nil { 63 + if errors.Is(err, sql.ErrNoRows) { 64 + writeError(w, xrpcErrorTag("EmailNotFound", "the email address is not associated with this account"), http.StatusNotFound) 65 + return 66 + } 67 + l.Error("failed to get email", "err", err) 68 + writeError(w, errInternal, http.StatusInternalServerError) 69 + return 70 + } 71 + if existing.Primary { 72 + writeError(w, xrpcErrorTag("CannotDeletePrimary", "the primary email address cannot be deleted; set another address as primary first"), http.StatusBadRequest) 73 + return 74 + } 75 + 76 + if err := db.DeleteEmail(x.DB, did, addr); err != nil { 77 + l.Error("failed to delete email", "err", err) 78 + writeError(w, errInternal, http.StatusInternalServerError) 79 + return 80 + } 81 + 82 + w.WriteHeader(http.StatusOK) 83 + } 84 + 85 + func (x *Xrpc) AccountSetPrimaryEmail(w http.ResponseWriter, r *http.Request) { 86 + l := x.Logger.With("handler", "AccountSetPrimaryEmail") 87 + 88 + did, ok := actorDid(r) 89 + if !ok { 90 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 91 + return 92 + } 93 + 94 + var input tangled.TempAccountSetPrimaryEmail_Input 95 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 96 + writeError(w, errBadRequestBody, http.StatusBadRequest) 97 + return 98 + } 99 + addr := strings.TrimSpace(input.Email) 100 + 101 + existing, err := db.GetEmail(x.DB, did, addr) 102 + if err != nil { 103 + if errors.Is(err, sql.ErrNoRows) { 104 + writeError(w, xrpcErrorTag("EmailNotFound", "the email address is not associated with this account"), http.StatusNotFound) 105 + return 106 + } 107 + l.Error("failed to get email", "err", err) 108 + writeError(w, errInternal, http.StatusInternalServerError) 109 + return 110 + } 111 + if !existing.Verified { 112 + writeError(w, xrpcErrorTag("EmailNotVerified", "the email address must be verified before it can be made primary"), http.StatusBadRequest) 113 + return 114 + } 115 + 116 + if err := db.MakeEmailPrimary(x.DB, did, addr); err != nil { 117 + l.Error("failed to set primary email", "err", err) 118 + writeError(w, errInternal, http.StatusInternalServerError) 119 + return 120 + } 121 + 122 + w.WriteHeader(http.StatusOK) 123 + } 124 + 125 + func (x *Xrpc) AccountSubscribeNewsletter(w http.ResponseWriter, r *http.Request) { 126 + l := x.Logger.With("handler", "AccountSubscribeNewsletter") 127 + 128 + did, ok := actorDid(r) 129 + if !ok { 130 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 131 + return 132 + } 133 + 134 + primary, err := db.GetPrimaryEmail(x.DB, did) 135 + if err != nil || primary.Address == "" { 136 + writeError(w, xrpcErrorTag("NoVerifiedEmail", "a primary email address is required to subscribe"), http.StatusBadRequest) 137 + return 138 + } 139 + 140 + if err := db.UpsertNewsletterPref(x.DB, did, db.NewsletterStatusSubscribed, primary.Address); err != nil { 141 + l.Error("failed to persist newsletter preference", "err", err) 142 + writeError(w, errInternal, http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + if x.Config.Resend.ApiKey != "" && x.Config.Resend.NewsletterSegmentId != "" { 147 + go func() { 148 + if err := email.AddNewsletterContact(x.Config.Resend.ApiKey, x.Config.Resend.NewsletterSegmentId, primary.Address); err != nil { 149 + l.Error("failed to add newsletter contact", "err", err) 150 + } 151 + }() 152 + } 153 + 154 + w.WriteHeader(http.StatusOK) 155 + } 156 + 157 + func (x *Xrpc) AccountDismissNewsletter(w http.ResponseWriter, r *http.Request) { 158 + l := x.Logger.With("handler", "AccountDismissNewsletter") 159 + 160 + did, ok := actorDid(r) 161 + if !ok { 162 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 163 + return 164 + } 165 + 166 + if err := db.UpsertNewsletterPref(x.DB, did, db.NewsletterStatusDismissed, ""); err != nil { 167 + l.Error("failed to persist newsletter dismissal", "err", err) 168 + writeError(w, errInternal, http.StatusInternalServerError) 169 + return 170 + } 171 + 172 + w.WriteHeader(http.StatusOK) 173 + }
+119
appview/xrpc/focus.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/appview/db" 9 + "tangled.org/core/appview/models" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) FocusBegin(w http.ResponseWriter, r *http.Request) { 14 + l := x.Logger.With("handler", "FocusBegin") 15 + 16 + did, ok := actorDid(r) 17 + if !ok { 18 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 19 + return 20 + } 21 + 22 + if err := db.BeginFocus(x.DB, did); err != nil { 23 + l.Error("failed to begin focus", "err", err) 24 + writeError(w, errInternal, http.StatusInternalServerError) 25 + return 26 + } 27 + 28 + item, err := db.GetNextFocusItem(x.DB, did) 29 + if err != nil { 30 + l.Error("failed to get first focus item", "err", err) 31 + _ = db.EndFocus(x.DB, did) 32 + writeError(w, errInternal, http.StatusInternalServerError) 33 + return 34 + } 35 + 36 + if item == nil { 37 + _ = db.EndFocus(x.DB, did) 38 + x.writeJSON(w, &tangled.TempFocusBeginSession_Output{}) 39 + return 40 + } 41 + 42 + out := &tangled.TempFocusBeginSession_Output{NotificationId: &item.ID} 43 + setFocusSubject(item, &out.RepoDid, &out.IssueAt, &out.PullAt) 44 + 45 + x.writeJSON(w, out) 46 + } 47 + 48 + func (x *Xrpc) FocusNext(w http.ResponseWriter, r *http.Request) { 49 + l := x.Logger.With("handler", "FocusNext") 50 + 51 + did, ok := actorDid(r) 52 + if !ok { 53 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 54 + return 55 + } 56 + 57 + var input tangled.TempFocusNextItem_Input 58 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 59 + writeError(w, errBadRequestBody, http.StatusBadRequest) 60 + return 61 + } 62 + 63 + if err := db.MarkNotificationRead(x.DB, input.CurrentId, did); err != nil { 64 + l.Warn("failed to mark notification read", "id", input.CurrentId, "err", err) 65 + } 66 + 67 + item, err := db.GetNextFocusItem(x.DB, did) 68 + if err != nil { 69 + l.Error("failed to get next focus item", "err", err) 70 + writeError(w, errInternal, http.StatusInternalServerError) 71 + return 72 + } 73 + 74 + if item == nil { 75 + _ = db.EndFocus(x.DB, did) 76 + x.writeJSON(w, &tangled.TempFocusNextItem_Output{}) 77 + return 78 + } 79 + 80 + out := &tangled.TempFocusNextItem_Output{NotificationId: &item.ID} 81 + setFocusSubject(item, &out.RepoDid, &out.IssueAt, &out.PullAt) 82 + 83 + x.writeJSON(w, out) 84 + } 85 + 86 + func (x *Xrpc) FocusEnd(w http.ResponseWriter, r *http.Request) { 87 + l := x.Logger.With("handler", "FocusEnd") 88 + 89 + did, ok := actorDid(r) 90 + if !ok { 91 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 92 + return 93 + } 94 + 95 + if err := db.EndFocus(x.DB, did); err != nil { 96 + l.Error("failed to end focus", "err", err) 97 + writeError(w, errInternal, http.StatusInternalServerError) 98 + return 99 + } 100 + 101 + w.WriteHeader(http.StatusOK) 102 + } 103 + 104 + // setFocusSubject fills the repo did and (issue|pull) at-uri of a focus item so 105 + // the client can navigate to it. 106 + func setFocusSubject(n *models.NotificationWithEntity, repoDid, issueAt, pullAt **string) { 107 + if n.Repo != nil { 108 + s := n.Repo.RepoDid 109 + *repoDid = &s 110 + } 111 + if n.Issue != nil { 112 + s := n.Issue.AtUri().String() 113 + *issueAt = &s 114 + } 115 + if n.Pull != nil { 116 + s := n.Pull.AtUri().String() 117 + *pullAt = &s 118 + } 119 + }
+279
appview/xrpc/notifications.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + "tangled.org/core/orm" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) NotificationList(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "NotificationList") 19 + 20 + did, ok := actorDid(r) 21 + if !ok { 22 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 23 + return 24 + } 25 + 26 + q := r.URL.Query() 27 + readFilter := q.Get("read") 28 + categoryFilter := q.Get("category") 29 + 30 + filters := []orm.Filter{orm.FilterEq("recipient_did", did)} 31 + if readFilter == "unread" { 32 + filters = append(filters, orm.FilterEq("read", 0)) 33 + } 34 + switch categoryFilter { 35 + case "social": 36 + filters = append(filters, orm.FilterIn("type", models.SocialNotificationTypes)) 37 + case "work": 38 + filters = append(filters, orm.FilterIn("type", models.WorkNotificationTypes)) 39 + } 40 + 41 + limit := 50 42 + if s := q.Get("limit"); s != "" { 43 + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { 44 + limit = n 45 + } 46 + } 47 + 48 + notifications, err := db.GetNotificationsWithEntities(x.DB, pagination.Page{Limit: limit}, filters...) 49 + if err != nil { 50 + l.Error("failed to get notifications", "err", err) 51 + writeError(w, errInternal, http.StatusInternalServerError) 52 + return 53 + } 54 + 55 + unreadBase := []orm.Filter{ 56 + orm.FilterEq("recipient_did", did), 57 + orm.FilterEq("read", 0), 58 + } 59 + workUnread, _ := db.CountNotifications(x.DB, 60 + append(unreadBase, orm.FilterIn("type", models.WorkNotificationTypes))...) 61 + socialUnread, _ := db.CountNotifications(x.DB, 62 + append(unreadBase, orm.FilterIn("type", models.SocialNotificationTypes))...) 63 + 64 + items := make([]*tangled.TempNotificationListNotifications_Notification, 0, len(notifications)) 65 + for _, n := range notifications { 66 + item := &tangled.TempNotificationListNotifications_Notification{ 67 + Id: n.ID, 68 + Type: string(n.Type), 69 + Category: notificationCategory(n.Type), 70 + ActorDid: n.ActorDid, 71 + Read: n.Read, 72 + CreatedAt: n.Created.UTC().Format("2006-01-02T15:04:05.000Z"), 73 + } 74 + if n.Repo != nil { 75 + s := n.Repo.RepoDid 76 + item.RepoDid = &s 77 + } 78 + if n.Issue != nil { 79 + s := n.Issue.AtUri().String() 80 + item.IssueAt = &s 81 + } 82 + if n.Pull != nil { 83 + s := n.Pull.AtUri().String() 84 + item.PullAt = &s 85 + } 86 + items = append(items, item) 87 + } 88 + 89 + x.writeJSON(w, &tangled.TempNotificationListNotifications_Output{ 90 + Notifications: items, 91 + WorkUnreadCount: workUnread, 92 + SocialUnreadCount: socialUnread, 93 + }) 94 + } 95 + 96 + func (x *Xrpc) NotificationGetUnreadCount(w http.ResponseWriter, r *http.Request) { 97 + l := x.Logger.With("handler", "NotificationGetUnreadCount") 98 + 99 + did, ok := actorDid(r) 100 + if !ok { 101 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 102 + return 103 + } 104 + 105 + count, err := db.CountNotifications(x.DB, 106 + orm.FilterEq("recipient_did", did), 107 + orm.FilterEq("read", 0), 108 + ) 109 + if err != nil { 110 + l.Error("failed to count unread notifications", "err", err) 111 + writeError(w, errInternal, http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + x.writeJSON(w, &tangled.TempNotificationGetUnreadCount_Output{Count: count}) 116 + } 117 + 118 + func (x *Xrpc) NotificationUpdateSeen(w http.ResponseWriter, r *http.Request) { 119 + l := x.Logger.With("handler", "NotificationUpdateSeen") 120 + 121 + did, ok := actorDid(r) 122 + if !ok { 123 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 124 + return 125 + } 126 + 127 + var input tangled.TempNotificationUpdateSeen_Input 128 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 129 + writeError(w, errBadRequestBody, http.StatusBadRequest) 130 + return 131 + } 132 + 133 + var err error 134 + if input.Read { 135 + err = db.MarkNotificationRead(x.DB, input.Id, did) 136 + } else { 137 + err = db.MarkNotificationUnread(x.DB, input.Id, did) 138 + } 139 + if err != nil { 140 + l.Error("failed to update notification read state", "err", err) 141 + writeError(w, errInternal, http.StatusInternalServerError) 142 + return 143 + } 144 + 145 + w.WriteHeader(http.StatusOK) 146 + } 147 + 148 + func (x *Xrpc) NotificationMarkAllRead(w http.ResponseWriter, r *http.Request) { 149 + l := x.Logger.With("handler", "NotificationMarkAllRead") 150 + 151 + did, ok := actorDid(r) 152 + if !ok { 153 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 154 + return 155 + } 156 + 157 + if err := db.MarkAllNotificationsRead(x.DB, did); err != nil { 158 + l.Error("failed to mark all notifications read", "err", err) 159 + writeError(w, errInternal, http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + w.WriteHeader(http.StatusOK) 164 + } 165 + 166 + func (x *Xrpc) NotificationDelete(w http.ResponseWriter, r *http.Request) { 167 + l := x.Logger.With("handler", "NotificationDelete") 168 + 169 + did, ok := actorDid(r) 170 + if !ok { 171 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 172 + return 173 + } 174 + 175 + var input tangled.TempNotificationDeleteNotification_Input 176 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 177 + writeError(w, errBadRequestBody, http.StatusBadRequest) 178 + return 179 + } 180 + 181 + if err := db.DeleteNotification(x.DB, input.Id, did); err != nil { 182 + l.Error("failed to delete notification", "err", err) 183 + writeError(w, errInternal, http.StatusInternalServerError) 184 + return 185 + } 186 + 187 + w.WriteHeader(http.StatusOK) 188 + } 189 + 190 + func (x *Xrpc) NotificationGetPreferences(w http.ResponseWriter, r *http.Request) { 191 + l := x.Logger.With("handler", "NotificationGetPreferences") 192 + 193 + did, ok := actorDid(r) 194 + if !ok { 195 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 196 + return 197 + } 198 + 199 + prefs, err := db.GetNotificationPreference(x.DB, did) 200 + if err != nil { 201 + l.Error("failed to get notification preferences", "err", err) 202 + writeError(w, errInternal, http.StatusInternalServerError) 203 + return 204 + } 205 + 206 + x.writeJSON(w, &tangled.TempNotificationGetPreferences_Preferences{ 207 + EmailNotifications: prefs.EmailNotifications, 208 + Followed: prefs.Followed, 209 + IssueClosed: prefs.IssueClosed, 210 + IssueCommented: prefs.IssueCommented, 211 + IssueCreated: prefs.IssueCreated, 212 + PullCommented: prefs.PullCommented, 213 + PullCreated: prefs.PullCreated, 214 + PullMerged: prefs.PullMerged, 215 + RepoStarred: prefs.RepoStarred, 216 + UserMentioned: prefs.UserMentioned, 217 + }) 218 + } 219 + 220 + func (x *Xrpc) NotificationUpdatePreferences(w http.ResponseWriter, r *http.Request) { 221 + l := x.Logger.With("handler", "NotificationUpdatePreferences") 222 + 223 + did, ok := actorDid(r) 224 + if !ok { 225 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 226 + return 227 + } 228 + 229 + var input tangled.TempNotificationUpdatePreferences_Input 230 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 231 + writeError(w, errBadRequestBody, http.StatusBadRequest) 232 + return 233 + } 234 + 235 + existing, err := db.GetNotificationPreference(x.DB, did) 236 + if err != nil { 237 + l.Error("failed to get existing notification preferences", "err", err) 238 + writeError(w, errInternal, http.StatusInternalServerError) 239 + return 240 + } 241 + 242 + prefs := &models.NotificationPreferences{ 243 + UserDid: syntax.DID(did), 244 + RepoStarred: applyBoolPtr(existing.RepoStarred, input.RepoStarred), 245 + IssueCreated: applyBoolPtr(existing.IssueCreated, input.IssueCreated), 246 + IssueCommented: applyBoolPtr(existing.IssueCommented, input.IssueCommented), 247 + IssueClosed: applyBoolPtr(existing.IssueClosed, input.IssueClosed), 248 + PullCreated: applyBoolPtr(existing.PullCreated, input.PullCreated), 249 + PullCommented: applyBoolPtr(existing.PullCommented, input.PullCommented), 250 + PullMerged: applyBoolPtr(existing.PullMerged, input.PullMerged), 251 + Followed: applyBoolPtr(existing.Followed, input.Followed), 252 + UserMentioned: applyBoolPtr(existing.UserMentioned, input.UserMentioned), 253 + EmailNotifications: applyBoolPtr(existing.EmailNotifications, input.EmailNotifications), 254 + } 255 + 256 + if err := x.DB.UpdateNotificationPreferences(r.Context(), prefs); err != nil { 257 + l.Error("failed to update notification preferences", "err", err) 258 + writeError(w, errInternal, http.StatusInternalServerError) 259 + return 260 + } 261 + 262 + w.WriteHeader(http.StatusOK) 263 + } 264 + 265 + func notificationCategory(t models.NotificationType) string { 266 + for _, st := range models.SocialNotificationTypes { 267 + if st == t { 268 + return "social" 269 + } 270 + } 271 + return "work" 272 + } 273 + 274 + func applyBoolPtr(existing bool, update *bool) bool { 275 + if update != nil { 276 + return *update 277 + } 278 + return existing 279 + }
+157
appview/xrpc/search.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/sourcegraph/zoekt" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/codesearch" 14 + "tangled.org/core/appview/pagination" 15 + ) 16 + 17 + func (x *Xrpc) SearchSearchCode(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "SearchSearchCode") 19 + 20 + if x.CodeSearch == nil { 21 + writeError(w, notImplementedError("code search is not configured"), http.StatusNotImplemented) 22 + return 23 + } 24 + 25 + q := r.URL.Query() 26 + rawQuery := strings.TrimSpace(q.Get("q")) 27 + if rawQuery == "" { 28 + writeError(w, badRequestError("missing required parameter: q"), http.StatusBadRequest) 29 + return 30 + } 31 + 32 + // scope by the repo's own did (meta.did) and language; post-filtered below 33 + var scope []string 34 + var repoFilter syntax.DID 35 + if raw := strings.TrimSpace(q.Get("repoDid")); raw != "" { 36 + repoDid, err := syntax.ParseDID(raw) 37 + if err != nil { 38 + writeError(w, badRequestError("invalid repoDid"), http.StatusBadRequest) 39 + return 40 + } 41 + repoFilter = repoDid 42 + scope = append(scope, fmt.Sprintf("meta.did:%s", repoDid)) 43 + } 44 + if lang := strings.TrimSpace(q.Get("lang")); lang != "" { 45 + scope = append(scope, fmt.Sprintf("lang:%s", lang)) 46 + } 47 + queryStr := strings.TrimSpace(strings.Join(scope, " ") + " " + rawQuery) 48 + 49 + page := pagination.Page{Limit: 50} 50 + if s := q.Get("limit"); s != "" { 51 + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { 52 + page.Limit = n 53 + } 54 + } 55 + if s := q.Get("cursor"); s != "" { 56 + if n, err := strconv.Atoi(s); err == nil && n >= 0 { 57 + page.Offset = n 58 + } 59 + } 60 + 61 + res, err := x.CodeSearch.Search(r.Context(), queryStr, page) 62 + if err != nil { 63 + var repoErr *codesearch.RepoOnlyError 64 + if errors.As(err, &repoErr) { 65 + writeError(w, badRequestError("query only filters by repo name; use repo search instead"), http.StatusBadRequest) 66 + return 67 + } 68 + l.Error("code search failed", "err", err, "query", queryStr) 69 + writeError(w, errInternal, http.StatusInternalServerError) 70 + return 71 + } 72 + 73 + // meta.did is best-effort, so drop anything that isn't the requested repo 74 + filtered := res.Results 75 + if repoFilter != "" { 76 + filtered = filtered[:0] 77 + for _, item := range res.Results { 78 + if item.RepoDID == repoFilter { 79 + filtered = append(filtered, item) 80 + } 81 + } 82 + } 83 + 84 + results := make([]*tangled.TempSearchSearchCode_FileResult, 0, len(filtered)) 85 + for _, item := range filtered { 86 + fr := &tangled.TempSearchSearchCode_FileResult{ 87 + RepoDid: item.RepoDID.String(), 88 + Path: item.FilePath, 89 + } 90 + if item.Language != "" { 91 + lang := item.Language 92 + fr.Language = &lang 93 + } 94 + for _, c := range item.Chunks { 95 + fr.Chunks = append(fr.Chunks, &tangled.TempSearchSearchCode_Chunk{ 96 + Content: c.Content, 97 + LineStart: int64(c.ContentStartLine), 98 + Highlights: chunkHighlights(c.Content, c.ContentStartLine, c.Ranges), 99 + }) 100 + } 101 + results = append(results, fr) 102 + } 103 + 104 + out := &tangled.TempSearchSearchCode_Output{Results: results} 105 + if res.HasMore { 106 + cursor := strconv.Itoa(page.Offset + page.Limit) 107 + out.Cursor = &cursor 108 + } 109 + 110 + x.writeJSON(w, out) 111 + } 112 + 113 + // chunkHighlights maps zoekt (line, rune-column) ranges to byte-offset ranges 114 + // within the chunk's content string 115 + func chunkHighlights(content string, startLine int, ranges []zoekt.Range) []*tangled.TempSearchSearchCode_Highlight { 116 + if startLine < 1 { 117 + startLine = 1 118 + } 119 + lines := strings.SplitAfter(content, "\n") 120 + lineOffset := make([]int, len(lines)) 121 + off := 0 122 + for i, ln := range lines { 123 + lineOffset[i] = off 124 + off += len(ln) 125 + } 126 + 127 + // byteAt maps a 1-based (line, rune column) to a byte offset in content 128 + byteAt := func(lineNum, runeCol int) int { 129 + idx := lineNum - startLine 130 + if idx < 0 || idx >= len(lines) { 131 + return -1 132 + } 133 + col := runeCol - 1 134 + if col < 0 { 135 + col = 0 136 + } 137 + r := 0 138 + for bi := range lines[idx] { 139 + if r == col { 140 + return lineOffset[idx] + bi 141 + } 142 + r++ 143 + } 144 + return lineOffset[idx] + len(strings.TrimSuffix(lines[idx], "\n")) 145 + } 146 + 147 + var out []*tangled.TempSearchSearchCode_Highlight 148 + for _, rg := range ranges { 149 + s := byteAt(int(rg.Start.LineNumber), int(rg.Start.Column)) 150 + e := byteAt(int(rg.End.LineNumber), int(rg.End.Column)) 151 + if s < 0 || e < 0 || e <= s { 152 + continue 153 + } 154 + out = append(out, &tangled.TempSearchSearchCode_Highlight{Start: int64(s), End: int64(e)}) 155 + } 156 + return out 157 + }
+306
appview/xrpc/signup.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/email" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/state/userutil" 18 + ) 19 + 20 + func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { 21 + l := x.Logger.With("handler", "AccountBeginSignup") 22 + 23 + // signup is gated on cloudflare being configured, mirroring appview/signup 24 + if x.Cloudflare == nil { 25 + writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) 26 + return 27 + } 28 + 29 + var input tangled.TempAccountBeginSignup_Input 30 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 31 + writeError(w, errBadRequestBody, http.StatusBadRequest) 32 + return 33 + } 34 + 35 + if err := x.validateTurnstile(input.TurnstileToken, r); err != nil { 36 + l.Warn("turnstile validation failed", "err", err, "email", input.Email) 37 + writeError(w, xrpcErrorTag("InvalidTurnstileToken", "captcha validation failed"), http.StatusForbidden) 38 + return 39 + } 40 + 41 + if !email.IsValidEmail(input.Email) { 42 + writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) 43 + return 44 + } 45 + 46 + exists, err := db.CheckEmailExistsAtAll(x.DB, input.Email) 47 + if err != nil { 48 + l.Error("failed to check email existence", "err", err) 49 + writeError(w, errInternal, http.StatusInternalServerError) 50 + return 51 + } 52 + if exists { 53 + writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) 54 + return 55 + } 56 + 57 + // the verification code is an invite code minted by the PDS 58 + code, err := x.pdsCreateInviteCode() 59 + if err != nil { 60 + l.Error("failed to create invite code", "err", err) 61 + writeError(w, errUpstream, http.StatusBadGateway) 62 + return 63 + } 64 + 65 + em := email.Email{ 66 + APIKey: x.Config.Resend.ApiKey, 67 + From: x.Config.Resend.SentFrom, 68 + To: input.Email, 69 + Subject: "Verify your Tangled account", 70 + Text: "Copy and paste this code below to verify your account on Tangled.\n" + code, 71 + Html: "<p>Copy and paste this code below to verify your account on Tangled.</p>\n<p><code>" + code + "</code></p>", 72 + } 73 + if err := email.SendEmail(em); err != nil { 74 + l.Error("failed to send verification email", "err", err) 75 + writeError(w, errInternal, http.StatusInternalServerError) 76 + return 77 + } 78 + 79 + if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: input.Email, InviteCode: code}); err != nil { 80 + l.Error("failed to add inflight signup", "err", err) 81 + writeError(w, errInternal, http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + w.WriteHeader(http.StatusOK) 86 + } 87 + 88 + func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { 89 + l := x.Logger.With("handler", "AccountCompleteSignup") 90 + 91 + if x.Cloudflare == nil { 92 + writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) 93 + return 94 + } 95 + 96 + var input tangled.TempAccountCompleteSignup_Input 97 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 98 + writeError(w, errBadRequestBody, http.StatusBadRequest) 99 + return 100 + } 101 + 102 + if !userutil.IsValidSubdomain(input.Username) { 103 + writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest) 104 + return 105 + } 106 + if x.DisallowedNicknames[strings.ToLower(input.Username)] { 107 + writeError(w, xrpcErrorTag("UsernameUnavailable", "this username is not available"), http.StatusConflict) 108 + return 109 + } 110 + 111 + emailAddr, err := db.GetEmailForCode(x.DB, input.Code) 112 + if err != nil { 113 + l.Error("failed to get email for code", "err", err) 114 + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest) 115 + return 116 + } 117 + 118 + did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code) 119 + if err != nil { 120 + l.Error("failed to provision account", "err", err) 121 + writeError(w, errUpstream, http.StatusBadGateway) 122 + return 123 + } 124 + 125 + go func() { 126 + if err := db.DeleteInflightSignup(x.DB, emailAddr); err != nil { 127 + l.Error("failed to delete inflight signup", "err", err) 128 + } 129 + }() 130 + 131 + x.writeJSON(w, &tangled.TempAccountCompleteSignup_Output{Did: did, Handle: handle}) 132 + } 133 + 134 + // provisionAccount creates the pds account, records its verified primary email, 135 + // and auto-claims the sites subdomain, rolling back on failure. 136 + func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did, handle string, err error) { 137 + success := false 138 + emailAdded := false 139 + defer func() { 140 + if success { 141 + return 142 + } 143 + x.Logger.Info("rolling back signup", "username", username, "did", did) 144 + if did != "" { 145 + if derr := x.pdsDeleteAccount(did); derr != nil { 146 + x.Logger.Error("failed to roll back PDS account", "err", derr, "did", did) 147 + } 148 + } 149 + if emailAdded { 150 + if derr := db.DeleteEmail(x.DB, did, emailAddr); derr != nil { 151 + x.Logger.Error("failed to roll back email row", "err", derr, "email", emailAddr) 152 + } 153 + } 154 + }() 155 + 156 + did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code) 157 + if err != nil { 158 + return "", "", err 159 + } 160 + 161 + if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil { 162 + return "", "", err 163 + } 164 + emailAdded = true 165 + 166 + // auto-claim <username>.<pds domain>: the only way to get a pds-domain site 167 + pdsDomain := strings.TrimPrefix(x.Config.Pds.Host, "https://") 168 + pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 169 + autoClaim := username + "." + pdsDomain 170 + if err := db.ClaimDomain(x.DB, did, autoClaim); err != nil { 171 + x.Logger.Warn("failed to auto-claim sites domain at signup", "domain", autoClaim, "did", did, "err", err) 172 + } 173 + 174 + success = true 175 + return did, handle, nil 176 + } 177 + 178 + func (x *Xrpc) validateTurnstile(token string, r *http.Request) error { 179 + if token == "" { 180 + return errors.New("captcha token is empty") 181 + } 182 + if x.Config.Cloudflare.Turnstile.SecretKey == "" { 183 + return errors.New("turnstile secret key not configured") 184 + } 185 + 186 + data := url.Values{} 187 + data.Set("secret", x.Config.Cloudflare.Turnstile.SecretKey) 188 + data.Set("response", token) 189 + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { 190 + data.Set("remoteip", ip) 191 + } else if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { 192 + if parts := strings.Split(fwd, ","); len(parts) > 0 { 193 + data.Set("remoteip", strings.TrimSpace(parts[0])) 194 + } 195 + } else { 196 + data.Set("remoteip", r.RemoteAddr) 197 + } 198 + 199 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 200 + if err != nil { 201 + return fmt.Errorf("failed to verify turnstile token: %w", err) 202 + } 203 + defer resp.Body.Close() 204 + 205 + var tr struct { 206 + Success bool `json:"success"` 207 + ErrorCodes []string `json:"error-codes,omitempty"` 208 + } 209 + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { 210 + return fmt.Errorf("failed to decode turnstile response: %w", err) 211 + } 212 + if !tr.Success { 213 + return fmt.Errorf("turnstile validation failed: %v", tr.ErrorCodes) 214 + } 215 + return nil 216 + } 217 + 218 + // pdsRequest posts to a pds xrpc endpoint; useAuth sends the admin secret via 219 + // basic auth. these are unauth'd or admin-authed, so they use raw http. 220 + func (x *Xrpc) pdsRequest(endpoint string, body any, useAuth bool) (*http.Response, error) { 221 + jsonData, err := json.Marshal(body) 222 + if err != nil { 223 + return nil, err 224 + } 225 + u := fmt.Sprintf("%s/xrpc/%s", x.Config.Pds.Host, endpoint) 226 + req, err := http.NewRequest(http.MethodPost, u, bytes.NewBuffer(jsonData)) 227 + if err != nil { 228 + return nil, err 229 + } 230 + req.Header.Set("Content-Type", "application/json") 231 + if useAuth { 232 + req.SetBasicAuth("admin", x.Config.Pds.AdminSecret) 233 + } 234 + return http.DefaultClient.Do(req) 235 + } 236 + 237 + func pdsError(resp *http.Response, action string) error { 238 + var e struct { 239 + Error string `json:"error"` 240 + Message string `json:"message"` 241 + } 242 + b, _ := io.ReadAll(resp.Body) 243 + if err := json.Unmarshal(b, &e); err == nil && e.Message != "" { 244 + return fmt.Errorf("failed to %s: %s - %s", action, e.Error, e.Message) 245 + } 246 + return fmt.Errorf("failed to %s, status %d", action, resp.StatusCode) 247 + } 248 + 249 + func (x *Xrpc) pdsCreateInviteCode() (string, error) { 250 + resp, err := x.pdsRequest("com.atproto.server.createInviteCode", map[string]any{"useCount": 1}, true) 251 + if err != nil { 252 + return "", err 253 + } 254 + defer resp.Body.Close() 255 + if resp.StatusCode != http.StatusOK { 256 + return "", pdsError(resp, "create invite code") 257 + } 258 + var result map[string]string 259 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 260 + return "", fmt.Errorf("failed to decode invite code response: %w", err) 261 + } 262 + return result["code"], nil 263 + } 264 + 265 + func (x *Xrpc) pdsCreateAccount(username, password, emailAddr, code string) (did, handle string, err error) { 266 + parsed, err := url.Parse(x.Config.Pds.Host) 267 + if err != nil { 268 + return "", "", fmt.Errorf("invalid PDS host URL: %w", err) 269 + } 270 + handle = fmt.Sprintf("%s.%s", username, parsed.Hostname()) 271 + 272 + body := map[string]string{ 273 + "email": emailAddr, 274 + "handle": handle, 275 + "password": password, 276 + "inviteCode": code, 277 + } 278 + resp, err := x.pdsRequest("com.atproto.server.createAccount", body, false) 279 + if err != nil { 280 + return "", "", err 281 + } 282 + defer resp.Body.Close() 283 + if resp.StatusCode != http.StatusOK { 284 + return "", "", pdsError(resp, "create account") 285 + } 286 + 287 + var result struct { 288 + DID string `json:"did"` 289 + } 290 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 291 + return "", "", fmt.Errorf("failed to decode create account response: %w", err) 292 + } 293 + return result.DID, handle, nil 294 + } 295 + 296 + func (x *Xrpc) pdsDeleteAccount(did string) error { 297 + resp, err := x.pdsRequest("com.atproto.admin.deleteAccount", map[string]string{"did": did}, true) 298 + if err != nil { 299 + return err 300 + } 301 + defer resp.Body.Close() 302 + if resp.StatusCode != http.StatusOK { 303 + return pdsError(resp, "delete account") 304 + } 305 + return nil 306 + }
+329
appview/xrpc/sites.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "net/http" 8 + "path" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/appview/sites" 16 + "tangled.org/core/appview/state/userutil" 17 + xrpcerr "tangled.org/core/xrpc/errors" 18 + ) 19 + 20 + func (x *Xrpc) SiteGetDomainClaim(w http.ResponseWriter, r *http.Request) { 21 + l := x.Logger.With("handler", "SiteGetDomainClaim") 22 + 23 + did, ok := actorDid(r) 24 + if !ok { 25 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 26 + return 27 + } 28 + 29 + claim, err := db.GetActiveDomainClaimForDid(x.DB, did) 30 + if err != nil { 31 + l.Error("failed to get domain claim", "err", err) 32 + writeError(w, errInternal, http.StatusInternalServerError) 33 + return 34 + } 35 + 36 + out := &tangled.TempSiteGetDomainClaim_Output{} 37 + if claim != nil { 38 + out.Domain = &claim.Domain 39 + } 40 + x.writeJSON(w, out) 41 + } 42 + 43 + func (x *Xrpc) SiteClaimDomain(w http.ResponseWriter, r *http.Request) { 44 + l := x.Logger.With("handler", "SiteClaimDomain") 45 + 46 + did, ok := actorDid(r) 47 + if !ok { 48 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 49 + return 50 + } 51 + 52 + var input tangled.TempSiteClaimDomain_Input 53 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 54 + writeError(w, errBadRequestBody, http.StatusBadRequest) 55 + return 56 + } 57 + 58 + subdomain := strings.TrimSpace(input.Subdomain) 59 + if len(subdomain) < 4 { 60 + writeError(w, xrpcErrorTag("InvalidSubdomain", "subdomain must be at least 4 characters long"), http.StatusBadRequest) 61 + return 62 + } 63 + if !userutil.IsValidSubdomain(subdomain) { 64 + writeError(w, xrpcErrorTag("InvalidSubdomain", "use only lowercase letters, digits, and hyphens; cannot start or end with a hyphen"), http.StatusBadRequest) 65 + return 66 + } 67 + if userutil.HasSlur(subdomain) { 68 + writeError(w, xrpcErrorTag("InvalidSubdomain", "that subdomain is not allowed"), http.StatusBadRequest) 69 + return 70 + } 71 + 72 + sitesDomain := x.Config.Sites.Domain 73 + if subdomain == sitesDomain { 74 + writeError(w, xrpcErrorTag("InvalidSubdomain", "cannot claim the root domain"), http.StatusBadRequest) 75 + return 76 + } 77 + fullDomain := subdomain + "." + sitesDomain 78 + 79 + if err := db.ClaimDomain(x.DB, did, fullDomain); err != nil { 80 + switch { 81 + case errors.Is(err, db.ErrDomainTaken): 82 + writeError(w, xrpcErrorTag("DomainTaken", err.Error()), http.StatusConflict) 83 + case errors.Is(err, db.ErrDomainCooldown): 84 + writeError(w, xrpcErrorTag("DomainCooldown", err.Error()), http.StatusConflict) 85 + case errors.Is(err, db.ErrAlreadyClaimed): 86 + writeError(w, xrpcErrorTag("AlreadyClaimed", err.Error()), http.StatusConflict) 87 + default: 88 + l.Error("claiming domain", "err", err) 89 + writeError(w, errInternal, http.StatusInternalServerError) 90 + } 91 + return 92 + } 93 + 94 + w.WriteHeader(http.StatusOK) 95 + } 96 + 97 + func (x *Xrpc) SiteReleaseDomain(w http.ResponseWriter, r *http.Request) { 98 + l := x.Logger.With("handler", "SiteReleaseDomain") 99 + 100 + did, ok := actorDid(r) 101 + if !ok { 102 + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 103 + return 104 + } 105 + 106 + var input tangled.TempSiteReleaseDomain_Input 107 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 108 + writeError(w, errBadRequestBody, http.StatusBadRequest) 109 + return 110 + } 111 + 112 + domain := strings.TrimSpace(input.Domain) 113 + if domain == "" { 114 + writeError(w, badRequestError("domain cannot be empty"), http.StatusBadRequest) 115 + return 116 + } 117 + 118 + // a tngl.sh handle's sites domain is auto-claimed at signup and handle-bound 119 + if isTngl, err := x.isTnglHandle(r.Context(), did); err != nil { 120 + l.Error("resolving identity", "err", err) 121 + writeError(w, errInternal, http.StatusInternalServerError) 122 + return 123 + } else if isTngl { 124 + writeError(w, xrpcErrorTag("HandleBoundDomain", "your tngl.sh domain is tied to your handle and cannot be released"), http.StatusBadRequest) 125 + return 126 + } 127 + 128 + if err := db.ReleaseDomain(x.DB, did, domain); err != nil { 129 + l.Error("releasing domain", "err", err) 130 + writeError(w, xrpcErrorTag("DomainNotFound", "unable to release domain; ensure it belongs to your account"), http.StatusNotFound) 131 + return 132 + } 133 + 134 + // clean up all site data for this did asynchronously 135 + if x.Cloudflare != nil && x.Cloudflare.Enabled() { 136 + siteConfigs, err := db.GetRepoSiteConfigsForDid(x.DB, did) 137 + if err != nil { 138 + l.Error("fetching site configs for cleanup", "err", err) 139 + } 140 + if err := db.DeleteRepoSiteConfigsForDid(x.DB, did); err != nil { 141 + l.Error("deleting site configs from db", "err", err) 142 + } 143 + 144 + go func() { 145 + ctx := context.Background() 146 + for _, sc := range siteConfigs { 147 + if err := sites.Delete(ctx, x.Cloudflare, did, sc.RepoRkey); err != nil { 148 + l.Error("R2 delete failed", "did", did, "repo", sc.RepoRkey, "err", err) 149 + } 150 + } 151 + if err := sites.DeleteAllDomainMappings(ctx, x.Cloudflare, domain); err != nil { 152 + l.Error("KV delete failed", "domain", domain, "err", err) 153 + } 154 + }() 155 + } 156 + 157 + w.WriteHeader(http.StatusOK) 158 + } 159 + 160 + func (x *Xrpc) SiteGetRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 161 + l := x.Logger.With("handler", "SiteGetRepoSiteConfig") 162 + 163 + repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) 164 + if xerr != nil { 165 + writeError(w, *xerr, status) 166 + return 167 + } 168 + 169 + config, err := db.GetRepoSiteConfig(x.DB, repo.RepoDid) 170 + if err != nil { 171 + l.Error("failed to get repo site config", "err", err) 172 + writeError(w, errInternal, http.StatusInternalServerError) 173 + return 174 + } 175 + 176 + out := &tangled.TempRepoGetSiteConfig_Output{} 177 + if config != nil { 178 + out.Config = &tangled.TempRepoGetSiteConfig_SiteConfig{ 179 + Branch: config.Branch, 180 + Dir: config.Dir, 181 + IsIndex: config.IsIndex, 182 + } 183 + } 184 + x.writeJSON(w, out) 185 + } 186 + 187 + func (x *Xrpc) SiteUpdateRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 188 + l := x.Logger.With("handler", "SiteUpdateRepoSiteConfig") 189 + 190 + var input tangled.TempRepoUpdateSiteConfig_Input 191 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 192 + writeError(w, errBadRequestBody, http.StatusBadRequest) 193 + return 194 + } 195 + 196 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 197 + if xerr != nil { 198 + writeError(w, *xerr, status) 199 + return 200 + } 201 + 202 + branch := strings.TrimSpace(input.Branch) 203 + if branch == "" { 204 + writeError(w, badRequestError("branch cannot be empty"), http.StatusBadRequest) 205 + return 206 + } 207 + 208 + dir := strings.TrimSpace(input.Dir) 209 + if dir == "" { 210 + dir = "/" 211 + } 212 + dir = path.Clean("/" + dir) 213 + if dir != "/" && strings.Contains(dir, "..") { 214 + writeError(w, badRequestError("invalid directory path"), http.StatusBadRequest) 215 + return 216 + } 217 + 218 + isIndex := input.IsIndex != nil && *input.IsIndex 219 + 220 + // check the claim before persisting, so a failed call leaves no state 221 + ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) 222 + if ownerClaim == nil { 223 + writeError(w, xrpcErrorTag("NoDomainClaim", "the account does not have an active domain claim"), http.StatusBadRequest) 224 + return 225 + } 226 + 227 + if err := db.SetRepoSiteConfig(x.DB, repo.RepoDid, branch, dir, isIndex); err != nil { 228 + l.Error("failed to save site config", "err", err) 229 + writeError(w, errInternal, http.StatusInternalServerError) 230 + return 231 + } 232 + 233 + if x.Cloudflare != nil && x.Cloudflare.Enabled() { 234 + go x.deploySite(repo, branch, dir, isIndex, ownerClaim.Domain) 235 + } else { 236 + l.Warn("cloudflare integration disabled; site won't be deployed", "repo", repo.RepoIdentifier()) 237 + } 238 + 239 + w.WriteHeader(http.StatusOK) 240 + } 241 + 242 + func (x *Xrpc) SiteDisableRepoSite(w http.ResponseWriter, r *http.Request) { 243 + l := x.Logger.With("handler", "SiteDisableRepoSite") 244 + 245 + var input tangled.TempRepoDisableSite_Input 246 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 247 + writeError(w, errBadRequestBody, http.StatusBadRequest) 248 + return 249 + } 250 + 251 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 252 + if xerr != nil { 253 + writeError(w, *xerr, status) 254 + return 255 + } 256 + 257 + existingConfig, _ := db.GetRepoSiteConfig(x.DB, repo.RepoDid) 258 + if existingConfig == nil { 259 + writeError(w, xrpcErrorTag("SiteNotFound", "no site configuration exists for this repository"), http.StatusNotFound) 260 + return 261 + } 262 + 263 + if err := db.DeleteRepoSiteConfig(x.DB, repo.RepoDid); err != nil { 264 + l.Error("failed to delete site config", "err", err) 265 + writeError(w, errInternal, http.StatusInternalServerError) 266 + return 267 + } 268 + 269 + if x.Cloudflare != nil && x.Cloudflare.Enabled() { 270 + ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) 271 + go func() { 272 + ctx := context.Background() 273 + if err := sites.Delete(ctx, x.Cloudflare, repo.Did, repo.Rkey); err != nil { 274 + l.Error("R2 delete failed", "repo", repo.RepoIdentifier(), "err", err) 275 + } 276 + if ownerClaim != nil { 277 + if err := sites.DeleteDomainMapping(ctx, x.Cloudflare, ownerClaim.Domain, repo.Name); err != nil { 278 + l.Error("KV delete failed", "domain", ownerClaim.Domain, "err", err) 279 + } 280 + } 281 + }() 282 + } 283 + 284 + w.WriteHeader(http.StatusOK) 285 + } 286 + 287 + // deploySite syncs a repo's site to r2 and writes the domain mapping, mirroring 288 + // the appview's SaveRepoSiteConfig deploy path 289 + func (x *Xrpc) deploySite(repo *models.Repo, branch, dir string, isIndex bool, domain string) { 290 + l := x.Logger.With("handler", "deploySite", "repo", repo.RepoIdentifier()) 291 + ctx := context.Background() 292 + 293 + deploy := &models.SiteDeploy{ 294 + RepoDid: syntax.DID(repo.RepoDid), 295 + Branch: branch, 296 + Dir: dir, 297 + Trigger: models.SiteDeployTriggerConfigChange, 298 + } 299 + 300 + deployErr := sites.Deploy(ctx, x.Cloudflare, x.Config, repo, branch, dir) 301 + if deployErr != nil { 302 + l.Error("initial R2 sync failed", "err", deployErr) 303 + deploy.Status = models.SiteDeployStatusFailure 304 + deploy.Error = deployErr.Error() 305 + } else { 306 + deploy.Status = models.SiteDeployStatusSuccess 307 + } 308 + 309 + if err := db.AddSiteDeploy(x.DB, deploy); err != nil { 310 + l.Error("failed to record deploy", "err", err) 311 + } 312 + 313 + if deployErr == nil { 314 + if err := sites.PutDomainMapping(ctx, x.Cloudflare, domain, repo.Did, repo.Name, repo.Rkey, isIndex); err != nil { 315 + l.Error("KV write failed", "domain", domain, "err", err) 316 + } 317 + } 318 + } 319 + 320 + // isTnglHandle reports whether the account's handle sits under the PDS user 321 + // domain (e.g. *.tngl.sh). Such users have a handle-bound sites domain that was 322 + // auto-claimed at signup and must not be released. 323 + func (x *Xrpc) isTnglHandle(ctx context.Context, did string) (bool, error) { 324 + ident, err := x.IdResolver.ResolveIdent(ctx, did) 325 + if err != nil { 326 + return false, err 327 + } 328 + return strings.HasSuffix(ident.Handle.String(), x.Config.Pds.UserDomain), nil 329 + }
+382
appview/xrpc/webhooks.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/hostutil" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 + ) 17 + 18 + // resolveOwnedRepo loads the repo by its DID and checks the actor owns it 19 + func (x *Xrpc) resolveOwnedRepo(r *http.Request, repoDid string) (*models.Repo, *xrpcerr.XrpcError, int) { 20 + did, ok := actorDid(r) 21 + if !ok { 22 + e := xrpcerr.MissingActorDidError 23 + return nil, &e, http.StatusForbidden 24 + } 25 + 26 + repo, err := db.GetRepoByDid(x.DB, repoDid) 27 + if err != nil { 28 + e := notFoundError("repo not found") 29 + return nil, &e, http.StatusNotFound 30 + } 31 + 32 + if repo.Did != did { 33 + e := xrpcerr.AccessControlError(did) 34 + return nil, &e, http.StatusForbidden 35 + } 36 + 37 + return repo, nil, http.StatusOK 38 + } 39 + 40 + func (x *Xrpc) WebhookList(w http.ResponseWriter, r *http.Request) { 41 + l := x.Logger.With("handler", "WebhookList") 42 + 43 + repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) 44 + if xerr != nil { 45 + writeError(w, *xerr, status) 46 + return 47 + } 48 + 49 + webhooks, err := db.GetWebhooksForRepo(x.DB, string(repo.RepoDid)) 50 + if err != nil { 51 + l.Error("failed to get webhooks", "err", err) 52 + writeError(w, errInternal, http.StatusInternalServerError) 53 + return 54 + } 55 + 56 + items := make([]*tangled.TempRepoListWebhooks_Webhook, 0, len(webhooks)) 57 + for i := range webhooks { 58 + wh := &webhooks[i] 59 + updated := wh.UpdatedAt.UTC().Format(timeFormat) 60 + items = append(items, &tangled.TempRepoListWebhooks_Webhook{ 61 + Id: wh.Id, 62 + Url: wh.Url, 63 + Active: wh.Active, 64 + Events: wh.Events, 65 + CreatedAt: wh.CreatedAt.UTC().Format(timeFormat), 66 + UpdatedAt: &updated, 67 + }) 68 + } 69 + 70 + x.writeJSON(w, &tangled.TempRepoListWebhooks_Output{Webhooks: items}) 71 + } 72 + 73 + func (x *Xrpc) WebhookCreate(w http.ResponseWriter, r *http.Request) { 74 + l := x.Logger.With("handler", "WebhookCreate") 75 + 76 + var input tangled.TempRepoCreateWebhook_Input 77 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 78 + writeError(w, errBadRequestBody, http.StatusBadRequest) 79 + return 80 + } 81 + 82 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 83 + if xerr != nil { 84 + writeError(w, *xerr, status) 85 + return 86 + } 87 + 88 + url := strings.TrimSpace(input.Url) 89 + if err := hostutil.ValidateExternalURL(url, x.Config.Core.Dev); err != nil { 90 + writeError(w, badRequestError(err.Error()), http.StatusBadRequest) 91 + return 92 + } 93 + if len(input.Events) == 0 { 94 + writeError(w, xrpcErrorTag("NoEventsSelected", "at least one event must be specified"), http.StatusBadRequest) 95 + return 96 + } 97 + 98 + active := true 99 + if input.Active != nil { 100 + active = *input.Active 101 + } 102 + secret := "" 103 + if input.Secret != nil { 104 + secret = strings.TrimSpace(*input.Secret) 105 + } 106 + 107 + webhook := &models.Webhook{ 108 + RepoDid: syntax.DID(repo.RepoDid), 109 + Url: url, 110 + Secret: secret, 111 + Active: active, 112 + Events: input.Events, 113 + } 114 + 115 + tx, err := x.DB.Begin() 116 + if err != nil { 117 + l.Error("failed to start transaction", "err", err) 118 + writeError(w, errInternal, http.StatusInternalServerError) 119 + return 120 + } 121 + defer tx.Rollback() 122 + 123 + if err := db.AddWebhook(tx, webhook); err != nil { 124 + l.Error("failed to add webhook", "err", err) 125 + writeError(w, errInternal, http.StatusInternalServerError) 126 + return 127 + } 128 + if err := tx.Commit(); err != nil { 129 + l.Error("failed to commit transaction", "err", err) 130 + writeError(w, errInternal, http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + x.writeJSON(w, &tangled.TempRepoCreateWebhook_Output{Id: webhook.Id}) 135 + } 136 + 137 + func (x *Xrpc) WebhookUpdate(w http.ResponseWriter, r *http.Request) { 138 + l := x.Logger.With("handler", "WebhookUpdate") 139 + 140 + var input tangled.TempRepoUpdateWebhook_Input 141 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 142 + writeError(w, errBadRequestBody, http.StatusBadRequest) 143 + return 144 + } 145 + 146 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 147 + if xerr != nil { 148 + writeError(w, *xerr, status) 149 + return 150 + } 151 + 152 + webhook, err := db.GetWebhook(x.DB, input.Id) 153 + if err != nil || string(webhook.RepoDid) != repo.RepoDid { 154 + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) 155 + return 156 + } 157 + 158 + if input.Url != nil { 159 + url := strings.TrimSpace(*input.Url) 160 + if url != "" { 161 + if err := hostutil.ValidateExternalURL(url, x.Config.Core.Dev); err != nil { 162 + writeError(w, badRequestError(err.Error()), http.StatusBadRequest) 163 + return 164 + } 165 + webhook.Url = url 166 + } 167 + } 168 + if input.Secret != nil { 169 + webhook.Secret = strings.TrimSpace(*input.Secret) 170 + } 171 + if input.Active != nil { 172 + webhook.Active = *input.Active 173 + } 174 + if len(input.Events) > 0 { 175 + webhook.Events = input.Events 176 + } 177 + 178 + tx, err := x.DB.Begin() 179 + if err != nil { 180 + l.Error("failed to start transaction", "err", err) 181 + writeError(w, errInternal, http.StatusInternalServerError) 182 + return 183 + } 184 + defer tx.Rollback() 185 + 186 + if err := db.UpdateWebhook(tx, webhook); err != nil { 187 + l.Error("failed to update webhook", "err", err) 188 + writeError(w, errInternal, http.StatusInternalServerError) 189 + return 190 + } 191 + if err := tx.Commit(); err != nil { 192 + l.Error("failed to commit transaction", "err", err) 193 + writeError(w, errInternal, http.StatusInternalServerError) 194 + return 195 + } 196 + 197 + w.WriteHeader(http.StatusOK) 198 + } 199 + 200 + func (x *Xrpc) WebhookDelete(w http.ResponseWriter, r *http.Request) { 201 + l := x.Logger.With("handler", "WebhookDelete") 202 + 203 + var input tangled.TempRepoDeleteWebhook_Input 204 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 205 + writeError(w, errBadRequestBody, http.StatusBadRequest) 206 + return 207 + } 208 + 209 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 210 + if xerr != nil { 211 + writeError(w, *xerr, status) 212 + return 213 + } 214 + 215 + webhook, err := db.GetWebhook(x.DB, input.Id) 216 + if err != nil || string(webhook.RepoDid) != repo.RepoDid { 217 + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) 218 + return 219 + } 220 + 221 + tx, err := x.DB.Begin() 222 + if err != nil { 223 + l.Error("failed to start transaction", "err", err) 224 + writeError(w, errInternal, http.StatusInternalServerError) 225 + return 226 + } 227 + defer tx.Rollback() 228 + 229 + if err := db.DeleteWebhook(tx, input.Id); err != nil { 230 + l.Error("failed to delete webhook", "err", err) 231 + writeError(w, errInternal, http.StatusInternalServerError) 232 + return 233 + } 234 + if err := tx.Commit(); err != nil { 235 + l.Error("failed to commit transaction", "err", err) 236 + writeError(w, errInternal, http.StatusInternalServerError) 237 + return 238 + } 239 + 240 + w.WriteHeader(http.StatusOK) 241 + } 242 + 243 + func (x *Xrpc) WebhookToggle(w http.ResponseWriter, r *http.Request) { 244 + l := x.Logger.With("handler", "WebhookToggle") 245 + 246 + var input tangled.TempRepoToggleWebhook_Input 247 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 248 + writeError(w, errBadRequestBody, http.StatusBadRequest) 249 + return 250 + } 251 + 252 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 253 + if xerr != nil { 254 + writeError(w, *xerr, status) 255 + return 256 + } 257 + 258 + webhook, err := db.GetWebhook(x.DB, input.Id) 259 + if err != nil || string(webhook.RepoDid) != repo.RepoDid { 260 + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) 261 + return 262 + } 263 + 264 + webhook.Active = !webhook.Active 265 + 266 + tx, err := x.DB.Begin() 267 + if err != nil { 268 + l.Error("failed to start transaction", "err", err) 269 + writeError(w, errInternal, http.StatusInternalServerError) 270 + return 271 + } 272 + defer tx.Rollback() 273 + 274 + if err := db.UpdateWebhook(tx, webhook); err != nil { 275 + l.Error("failed to toggle webhook", "err", err) 276 + writeError(w, errInternal, http.StatusInternalServerError) 277 + return 278 + } 279 + if err := tx.Commit(); err != nil { 280 + l.Error("failed to commit transaction", "err", err) 281 + writeError(w, errInternal, http.StatusInternalServerError) 282 + return 283 + } 284 + 285 + x.writeJSON(w, &tangled.TempRepoToggleWebhook_Output{Active: webhook.Active}) 286 + } 287 + 288 + func (x *Xrpc) WebhookListDeliveries(w http.ResponseWriter, r *http.Request) { 289 + l := x.Logger.With("handler", "WebhookListDeliveries") 290 + 291 + q := r.URL.Query() 292 + repo, xerr, status := x.resolveOwnedRepo(r, q.Get("repoDid")) 293 + if xerr != nil { 294 + writeError(w, *xerr, status) 295 + return 296 + } 297 + 298 + id, err := strconv.ParseInt(q.Get("id"), 10, 64) 299 + if err != nil { 300 + writeError(w, badRequestError("invalid webhook id"), http.StatusBadRequest) 301 + return 302 + } 303 + 304 + webhook, err := db.GetWebhook(x.DB, id) 305 + if err != nil || string(webhook.RepoDid) != repo.RepoDid { 306 + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) 307 + return 308 + } 309 + 310 + limit := 100 311 + if s := q.Get("limit"); s != "" { 312 + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { 313 + limit = n 314 + } 315 + } 316 + 317 + deliveries, err := db.GetWebhookDeliveries(x.DB, webhook.Id, limit) 318 + if err != nil { 319 + l.Error("failed to get webhook deliveries", "err", err) 320 + writeError(w, errInternal, http.StatusInternalServerError) 321 + return 322 + } 323 + 324 + items := make([]*tangled.TempRepoListWebhookDeliveries_Delivery, 0, len(deliveries)) 325 + for i := range deliveries { 326 + d := &deliveries[i] 327 + item := &tangled.TempRepoListWebhookDeliveries_Delivery{ 328 + Id: d.Id, 329 + DeliveryId: d.DeliveryId, 330 + Event: d.Event, 331 + Url: d.Url, 332 + Success: d.Success, 333 + CreatedAt: d.CreatedAt.UTC().Format(timeFormat), 334 + } 335 + if d.RequestBody != "" { 336 + rb := d.RequestBody 337 + item.RequestBody = &rb 338 + } 339 + if d.ResponseBody != "" { 340 + rb := d.ResponseBody 341 + item.ResponseBody = &rb 342 + } 343 + if d.ResponseCode != 0 { 344 + rc := int64(d.ResponseCode) 345 + item.ResponseCode = &rc 346 + } 347 + items = append(items, item) 348 + } 349 + 350 + x.writeJSON(w, &tangled.TempRepoListWebhookDeliveries_Output{Deliveries: items}) 351 + } 352 + 353 + func (x *Xrpc) WebhookRetryDelivery(w http.ResponseWriter, r *http.Request) { 354 + var input tangled.TempRepoRetryWebhookDelivery_Input 355 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 356 + writeError(w, errBadRequestBody, http.StatusBadRequest) 357 + return 358 + } 359 + 360 + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 361 + if xerr != nil { 362 + writeError(w, *xerr, status) 363 + return 364 + } 365 + 366 + webhook, err := db.GetWebhook(x.DB, input.WebhookId) 367 + if err != nil || string(webhook.RepoDid) != repo.RepoDid { 368 + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) 369 + return 370 + } 371 + 372 + delivery, err := db.GetWebhookDelivery(x.DB, input.DeliveryId) 373 + if err != nil || delivery.WebhookId != webhook.Id { 374 + writeError(w, xrpcErrorTag("DeliveryNotFound", "delivery not found"), http.StatusNotFound) 375 + return 376 + } 377 + 378 + // re-dispatch async; the new attempt is recorded as its own delivery 379 + go x.Webhooks.Redeliver(context.Background(), *webhook, *delivery) 380 + 381 + w.WriteHeader(http.StatusOK) 382 + }
+184
appview/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 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 + 22 + const ActorDid = serviceauth.ActorDid 23 + 24 + type 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 + 38 + func (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 100 + const timeFormat = "2006-01-02T15:04:05.000Z" 101 + 102 + // health responds to /xrpc/_health with the running version 103 + func (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" 108 + func 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. 123 + func (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 + 144 + func 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 + 150 + func (x *Xrpc) writeJSON(w http.ResponseWriter, v any) { 151 + w.Header().Set("Content-Type", "application/json") 152 + json.NewEncoder(w).Encode(v) 153 + } 154 + 155 + func 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 164 + var ( 165 + errInternal = xrpcErrorTag("InternalError", "internal server error") 166 + errBadRequestBody = xrpcErrorTag("InvalidRequest", "invalid request body") 167 + errUpstream = xrpcErrorTag("UpstreamError", "an upstream service failed") 168 + ) 169 + 170 + func xrpcErrorTag(tag, message string) xrpcerr.XrpcError { 171 + return xrpcerr.NewXrpcError(xrpcerr.WithTag(tag), xrpcerr.WithMessage(message)) 172 + } 173 + 174 + func badRequestError(message string) xrpcerr.XrpcError { 175 + return xrpcErrorTag("InvalidRequest", message) 176 + } 177 + 178 + func notFoundError(message string) xrpcerr.XrpcError { 179 + return xrpcErrorTag("NotFound", message) 180 + } 181 + 182 + func notImplementedError(message string) xrpcerr.XrpcError { 183 + return xrpcErrorTag("MethodNotImplemented", message) 184 + }
+158
appview/xrpc/xrpc_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "net/http/httptest" 10 + "path/filepath" 11 + "testing" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/atcrypto" 15 + "github.com/bluesky-social/indigo/atproto/auth" 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/xrpc/serviceauth" 22 + ) 23 + 24 + const ( 25 + testActor = "did:plc:tester" 26 + testAudience = "did:web:test.example" 27 + ) 28 + 29 + // newTestXrpc builds an Xrpc backed by a fresh temp DB, with service auth wired 30 + // to a mock directory holding testActor's key. It returns the router, the DB, 31 + // and a function that signs a service-auth token for a given lexicon method. 32 + func newTestXrpc(t *testing.T) (http.Handler, *db.DB, func(nsid string) string) { 33 + t.Helper() 34 + 35 + d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) 36 + if err != nil { 37 + t.Fatalf("db.Make: %v", err) 38 + } 39 + t.Cleanup(func() { d.Close() }) 40 + 41 + priv, err := atcrypto.GeneratePrivateKeyP256() 42 + if err != nil { 43 + t.Fatalf("generate key: %v", err) 44 + } 45 + pub, err := priv.PublicKey() 46 + if err != nil { 47 + t.Fatalf("derive pubkey: %v", err) 48 + } 49 + 50 + dir := identity.NewMockDirectory() 51 + dir.Insert(identity.Identity{ 52 + DID: syntax.DID(testActor), 53 + Keys: map[string]identity.VerificationMethod{ 54 + "atproto": {Type: "Multikey", PublicKeyMultibase: pub.Multibase()}, 55 + }, 56 + }) 57 + 58 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 59 + x := &Xrpc{ 60 + DB: d, 61 + Config: &config.Config{}, 62 + Logger: logger, 63 + ServiceAuth: serviceauth.NewServiceAuth(logger, dir, testAudience), 64 + } 65 + 66 + sign := func(nsid string) string { 67 + lxm := syntax.NSID(nsid) 68 + token, err := auth.SignServiceAuth(syntax.DID(testActor), testAudience, time.Minute, &lxm, priv) 69 + if err != nil { 70 + t.Fatalf("sign service auth: %v", err) 71 + } 72 + return token 73 + } 74 + 75 + return x.Router(), d, sign 76 + } 77 + 78 + func TestHealth(t *testing.T) { 79 + router, _, _ := newTestXrpc(t) 80 + 81 + rec := httptest.NewRecorder() 82 + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_health", nil)) 83 + 84 + if rec.Code != http.StatusOK { 85 + t.Fatalf("status = %d, want 200", rec.Code) 86 + } 87 + var body map[string]string 88 + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { 89 + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) 90 + } 91 + if body["version"] == "" { 92 + t.Fatalf("missing version in %s", rec.Body.String()) 93 + } 94 + } 95 + 96 + func TestServiceAuthRequired(t *testing.T) { 97 + router, _, _ := newTestXrpc(t) 98 + 99 + rec := httptest.NewRecorder() 100 + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil)) 101 + 102 + if rec.Code != http.StatusForbidden { 103 + t.Fatalf("status = %d, want 403 without a service-auth token", rec.Code) 104 + } 105 + } 106 + 107 + func TestNotificationGetUnreadCount(t *testing.T) { 108 + router, d, sign := newTestXrpc(t) 109 + 110 + nsid := "org.tangled.temp.notification.getUnreadCount" 111 + call := func() int { 112 + req := httptest.NewRequest(http.MethodGet, "/"+nsid, nil) 113 + req.Header.Set("Authorization", "Bearer "+sign(nsid)) 114 + rec := httptest.NewRecorder() 115 + router.ServeHTTP(rec, req) 116 + if rec.Code != http.StatusOK { 117 + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 118 + } 119 + var out struct { 120 + Count int `json:"count"` 121 + } 122 + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { 123 + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) 124 + } 125 + return out.Count 126 + } 127 + 128 + if got := call(); got != 0 { 129 + t.Fatalf("empty db count = %d, want 0", got) 130 + } 131 + 132 + if err := db.CreateNotification(d, &models.Notification{ 133 + RecipientDid: testActor, 134 + ActorDid: "did:plc:someone", 135 + Type: models.NotificationTypeRepoStarred, 136 + Read: false, 137 + }); err != nil { 138 + t.Fatalf("CreateNotification: %v", err) 139 + } 140 + 141 + if got := call(); got != 1 { 142 + t.Fatalf("count after one unread = %d, want 1", got) 143 + } 144 + } 145 + 146 + func TestWrongLexiconTokenRejected(t *testing.T) { 147 + router, _, sign := newTestXrpc(t) 148 + 149 + // a token minted for a different method must not authorize this call 150 + req := httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil) 151 + req.Header.Set("Authorization", "Bearer "+sign("org.tangled.temp.notification.listNotifications")) 152 + rec := httptest.NewRecorder() 153 + router.ServeHTTP(rec, req) 154 + 155 + if rec.Code != http.StatusForbidden { 156 + t.Fatalf("status = %d, want 403 for a token bound to a different method", rec.Code) 157 + } 158 + }
+84
hostutil/safedial.go
··· 1 + package hostutil 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + "net/http" 7 + "net/url" 8 + "syscall" 9 + "time" 10 + ) 11 + 12 + // isBlockedIP reports whether ip is loopback, private, link-local (incl. the 13 + // 169.254.169.254 metadata endpoint), multicast, or unspecified. 14 + func isBlockedIP(ip net.IP) bool { 15 + return ip.IsLoopback() || 16 + ip.IsPrivate() || 17 + ip.IsLinkLocalUnicast() || 18 + ip.IsLinkLocalMulticast() || 19 + ip.IsMulticast() || 20 + ip.IsUnspecified() 21 + } 22 + 23 + // safeDialer rejects dials to non-public addresses. the Control hook runs after 24 + // dns resolution, so it also covers rebinding and redirects. disabled in dev. 25 + func safeDialer(dev bool) *net.Dialer { 26 + d := &net.Dialer{ 27 + Timeout: 10 * time.Second, 28 + KeepAlive: 30 * time.Second, 29 + } 30 + if dev { 31 + return d 32 + } 33 + d.Control = func(_, address string, _ syscall.RawConn) error { 34 + host, _, err := net.SplitHostPort(address) 35 + if err != nil { 36 + return fmt.Errorf("invalid dial address %q: %w", address, err) 37 + } 38 + ip := net.ParseIP(host) 39 + if ip == nil { 40 + return fmt.Errorf("dial address %q did not resolve to an IP", address) 41 + } 42 + if isBlockedIP(ip) { 43 + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) 44 + } 45 + return nil 46 + } 47 + return d 48 + } 49 + 50 + // ValidateExternalURL checks raw is a well-formed http(s) url and rejects 51 + // ip-literal hosts in blocked ranges; dns hosts are re-checked at dial time. 52 + func ValidateExternalURL(raw string, dev bool) error { 53 + u, err := url.Parse(raw) 54 + if err != nil { 55 + return fmt.Errorf("invalid URL: %w", err) 56 + } 57 + if u.Scheme != "http" && u.Scheme != "https" { 58 + return fmt.Errorf("URL must use http or https") 59 + } 60 + if u.Hostname() == "" { 61 + return fmt.Errorf("URL must include a host") 62 + } 63 + if dev { 64 + return nil 65 + } 66 + if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedIP(ip) { 67 + return fmt.Errorf("URL host is a reserved or private address") 68 + } 69 + return nil 70 + } 71 + 72 + // SafeClient returns an http.Client for fetching untrusted urls (e.g. 73 + // webhooks): it blocks internal address ranges and won't follow redirects. 74 + func SafeClient(dev bool, timeout time.Duration) *http.Client { 75 + return &http.Client{ 76 + Timeout: timeout, 77 + Transport: &http.Transport{ 78 + DialContext: safeDialer(dev).DialContext, 79 + }, 80 + CheckRedirect: func(*http.Request, []*http.Request) error { 81 + return http.ErrUseLastResponse 82 + }, 83 + } 84 + }