Monorepo for Tangled tangled.org
2

Configure Feed

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

1package oauth 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log/slog" 11 "net/http" 12 "strings" 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 lexutil "github.com/bluesky-social/indigo/lex/util" 19 xrpc "github.com/bluesky-social/indigo/xrpc" 20 "github.com/go-chi/chi/v5" 21 "github.com/posthog/posthog-go" 22 "tangled.org/core/api/tangled" 23 "tangled.org/core/appview/db" 24 "tangled.org/core/appview/knotcompat" 25 "tangled.org/core/appview/models" 26 "tangled.org/core/consts" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/orm" 29 "tangled.org/core/tid" 30) 31 32const knotAdminTimeout = 30 * time.Second 33 34func (o *OAuth) Router() http.Handler { 35 r := chi.NewRouter() 36 37 r.Get("/oauth/client-metadata.json", o.clientMetadata) 38 r.Get("/oauth/jwks.json", o.jwks) 39 r.Get("/oauth/callback", o.callback) 40 return r 41} 42 43func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 44 doc := o.ClientApp.Config.ClientMetadata() 45 doc.JWKSURI = &o.JwksUri 46 doc.ClientName = &o.ClientName 47 doc.ClientURI = &o.ClientUri 48 doc.Scope = doc.Scope + " identity:handle" 49 50 w.Header().Set("Content-Type", "application/json") 51 if err := json.NewEncoder(w).Encode(doc); err != nil { 52 http.Error(w, err.Error(), http.StatusInternalServerError) 53 return 54 } 55} 56 57func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 58 w.Header().Set("Content-Type", "application/json") 59 body := o.ClientApp.Config.PublicJWKS() 60 if err := json.NewEncoder(w).Encode(body); err != nil { 61 http.Error(w, err.Error(), http.StatusInternalServerError) 62 return 63 } 64} 65 66func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 67 ctx := r.Context() 68 l := o.Logger.With("query", r.URL.Query()) 69 70 redirectURL := o.GetAuthReturn(r) 71 _ = o.ClearAuthReturn(w, r) 72 73 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 74 if err != nil { 75 var callbackErr *oauth.AuthRequestCallbackError 76 if errors.As(err, &callbackErr) { 77 l.Debug("callback error", "err", callbackErr) 78 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 79 return 80 } 81 l.Error("failed to process callback", "err", err) 82 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 83 return 84 } 85 86 if err := o.SaveSession(w, r, sessData); err != nil { 87 l.Error("failed to save session", "data", sessData, "err", err) 88 errorCode := "session" 89 if errors.Is(err, ErrMaxAccountsReached) { 90 errorCode = "max_accounts" 91 } 92 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 93 return 94 } 95 96 o.Logger.Debug("session saved successfully") 97 98 go o.addToDefaultKnot(sessData.AccountDID) 99 go o.addToDefaultSpindle(sessData.AccountDID.String()) 100 go o.ensureTangledProfile(sessData) 101 go o.autoClaimTnglShDomain(sessData.AccountDID.String()) 102 103 if !o.Config.Core.Dev { 104 err = o.Posthog.Enqueue(posthog.Capture{ 105 DistinctId: sessData.AccountDID.String(), 106 Event: "signin", 107 }) 108 if err != nil { 109 o.Logger.Error("failed to enqueue posthog event", "err", err) 110 } 111 } 112 113 if redirectURL == "" { 114 redirectURL = "/" 115 } 116 117 if o.isAccountDeactivated(sessData) { 118 redirectURL = "/settings/profile" 119 } 120 121 http.Redirect(w, r, redirectURL, http.StatusFound) 122} 123 124func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { 125 pdsClient := &xrpc.Client{ 126 Host: sessData.HostURL, 127 Client: &http.Client{Timeout: 5 * time.Second}, 128 } 129 130 _, err := comatproto.RepoDescribeRepo( 131 context.Background(), 132 pdsClient, 133 sessData.AccountDID.String(), 134 ) 135 if err == nil { 136 return false 137 } 138 139 var xrpcErr *xrpc.Error 140 var xrpcBody *xrpc.XRPCError 141 return errors.As(err, &xrpcErr) && 142 errors.As(xrpcErr.Wrapped, &xrpcBody) && 143 xrpcBody.ErrStr == "RepoDeactivated" 144} 145 146func (o *OAuth) addToDefaultSpindle(did string) { 147 l := o.Logger.With("subject", did) 148 149 // use the tangled.sh app password to get an accessJwt 150 // and create an sh.tangled.spindle.member record with that 151 spindleMembers, err := db.GetSpindleMembers( 152 o.Db, 153 orm.FilterEq("instance", "spindle.tangled.sh"), 154 orm.FilterEq("subject", did), 155 ) 156 if err != nil { 157 l.Error("failed to get spindle members", "err", err) 158 return 159 } 160 161 if len(spindleMembers) != 0 { 162 l.Warn("already a member of the default spindle") 163 return 164 } 165 166 l.Debug("adding to default spindle") 167 session, err := o.getAppPasswordSession() 168 if err != nil { 169 l.Error("failed to create session", "err", err) 170 return 171 } 172 173 record := tangled.SpindleMember{ 174 LexiconTypeID: tangled.SpindleMemberNSID, 175 Subject: did, 176 Instance: consts.DefaultSpindle, 177 CreatedAt: time.Now().Format(time.RFC3339), 178 } 179 180 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 181 l.Error("failed to add to default spindle", "err", err) 182 return 183 } 184 185 l.Debug("successfully added to default spindle", "did", did) 186} 187 188type onboardAction int 189 190const ( 191 onboardViaAdminAPI onboardAction = iota 192 onboardViaRecord 193 onboardBlockedMissingSecret 194 onboardBlockedSecretSet 195) 196 197type defaultKnotState struct { 198 native bool 199 adminSecretSet bool 200} 201 202func onboardActionFor(s defaultKnotState) onboardAction { 203 switch { 204 case s.native && s.adminSecretSet: 205 return onboardViaAdminAPI 206 case s.native: 207 return onboardBlockedMissingSecret 208 case s.adminSecretSet: 209 return onboardBlockedSecretSet 210 default: 211 return onboardViaRecord 212 } 213} 214 215func (o *OAuth) addToDefaultKnot(did syntax.DID) { 216 l := o.Logger.With("subject", did) 217 218 ctx := context.Background() 219 220 if o.Acl.IsKnotMember(ctx, o.Config.Knot.Default, did.String()) { 221 l.Warn("already a member of the default knot") 222 return 223 } 224 225 native := knotcompat.KnotHasCapability(ctx, o.Config.Knot.Default, o.Config.Core.Dev, consts.CapKnotACL) 226 227 switch onboardActionFor(defaultKnotState{native: native, adminSecretSet: o.Config.Knot.AdminSecret != ""}) { 228 case onboardViaAdminAPI: 229 if err := o.addMemberViaKnotAdmin(ctx, o.Config.Knot.Default, did); err != nil { 230 l.Error("failed to add to default knot via admin api", "err", err) 231 return 232 } 233 o.Acl.InvalidateMembers(o.Config.Knot.Default) 234 l.Debug("successfully added to default knot via admin api") 235 236 case onboardBlockedMissingSecret: 237 l.Error("cannot add to default knot: knot admin secret not configured") 238 239 case onboardBlockedSecretSet: 240 l.Warn("default knot probe failed, skipping legacy fallback because an admin secret is configured") 241 242 case onboardViaRecord: 243 l.Debug("adding to default knot") 244 session, err := o.getAppPasswordSession() 245 if err != nil { 246 l.Error("failed to create session", "err", err) 247 return 248 } 249 250 record := tangled.KnotMember{ 251 LexiconTypeID: tangled.KnotMemberNSID, 252 Subject: did.String(), 253 Domain: o.Config.Knot.Default, 254 CreatedAt: time.Now().Format(time.RFC3339), 255 } 256 257 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 258 l.Error("failed to add to default knot", "err", err) 259 return 260 } 261 262 if err := o.Enforcer.AddKnotMember(o.Config.Knot.Default, did.String()); err != nil { 263 l.Error("failed to set up enforcer rules", "err", err) 264 return 265 } 266 267 l.Debug("successfully added to default knot") 268 } 269} 270 271func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error { 272 ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout) 273 defer cancel() 274 275 scheme := "https://" 276 if o.Config.Core.Dev { 277 scheme = "http://" 278 } 279 endpoint := fmt.Sprintf("%s%s/admin/addMember", scheme, knotHost) 280 281 body, err := json.Marshal(tangled.KnotAddMember_Input{Subject: subject.String()}) 282 if err != nil { 283 return err 284 } 285 286 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) 287 if err != nil { 288 return err 289 } 290 req.Header.Set("Content-Type", "application/json") 291 req.SetBasicAuth("admin", o.Config.Knot.AdminSecret) 292 293 resp, err := http.DefaultClient.Do(req) 294 if err != nil { 295 return err 296 } 297 defer resp.Body.Close() 298 299 if resp.StatusCode != http.StatusOK { 300 msg, _ := io.ReadAll(resp.Body) 301 return fmt.Errorf("knot admin addMember returned status %d: %s", resp.StatusCode, bytes.TrimSpace(msg)) 302 } 303 304 return nil 305} 306 307func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { 308 ctx := context.Background() 309 did := sessData.AccountDID.String() 310 l := o.Logger.With("did", did) 311 312 profile, _ := db.GetProfile(o.Db, did) 313 if profile != nil { 314 l.Debug("profile already exists in DB") 315 return 316 } 317 318 l.Debug("creating empty Tangled profile") 319 320 sess, err := o.resumeSession(ctx, sessData.AccountDID, sessData.SessionID) 321 if err != nil { 322 l.Error("failed to resume session for profile creation", "err", err) 323 return 324 } 325 client := sess.APIClient() 326 327 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 328 Collection: tangled.ActorProfileNSID, 329 Repo: did, 330 Rkey: "self", 331 Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}}, 332 }) 333 334 if err != nil { 335 l.Error("failed to create empty profile on PDS", "err", err) 336 return 337 } 338 339 tx, err := o.Db.BeginTx(ctx, nil) 340 if err != nil { 341 l.Error("failed to start transaction", "err", err) 342 return 343 } 344 345 emptyProfile := &models.Profile{Did: did} 346 if err := db.UpsertProfile(tx, emptyProfile); err != nil { 347 l.Error("failed to create empty profile in DB", "err", err) 348 return 349 } 350 351 l.Debug("successfully created empty Tangled profile on PDS and DB") 352} 353 354// create a AppPasswordSession using apppasswords 355type AppPasswordSession struct { 356 AccessJwt string `json:"accessJwt"` 357 RefreshJwt string `json:"refreshJwt"` 358 PdsEndpoint string 359 Did string 360 Logger *slog.Logger 361 ExpiresAt time.Time 362} 363 364func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) { 365 if appPassword == "" { 366 return nil, fmt.Errorf("no app password configured") 367 } 368 369 resolved, err := res.ResolveIdent(context.Background(), did) 370 if err != nil { 371 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 372 } 373 374 pdsEndpoint := resolved.PDSEndpoint() 375 if pdsEndpoint == "" { 376 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 377 } 378 379 sessionPayload := map[string]string{ 380 "identifier": did, 381 "password": appPassword, 382 } 383 sessionBytes, err := json.Marshal(sessionPayload) 384 if err != nil { 385 return nil, fmt.Errorf("failed to marshal session payload: %v", err) 386 } 387 388 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 389 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 390 if err != nil { 391 return nil, fmt.Errorf("failed to create session request: %v", err) 392 } 393 sessionReq.Header.Set("Content-Type", "application/json") 394 395 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header) 396 397 client := &http.Client{Timeout: 30 * time.Second} 398 sessionResp, err := client.Do(sessionReq) 399 if err != nil { 400 return nil, fmt.Errorf("failed to create session: %v", err) 401 } 402 defer sessionResp.Body.Close() 403 404 if sessionResp.StatusCode != http.StatusOK { 405 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 406 } 407 408 var session AppPasswordSession 409 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 410 return nil, fmt.Errorf("failed to decode session response: %v", err) 411 } 412 413 session.PdsEndpoint = pdsEndpoint 414 session.Did = did 415 session.Logger = logger 416 session.ExpiresAt = time.Now().Add(115 * time.Minute) 417 418 return &session, nil 419} 420 421func (s *AppPasswordSession) RefreshSession() error { 422 refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession" 423 req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil) 424 if err != nil { 425 return fmt.Errorf("failed to create refresh request: %w", err) 426 } 427 428 req.Header.Set("Authorization", "Bearer "+s.RefreshJwt) 429 430 s.Logger.Debug("refreshing app password session", "url", refreshURL) 431 432 client := &http.Client{Timeout: 30 * time.Second} 433 resp, err := client.Do(req) 434 if err != nil { 435 return fmt.Errorf("failed to refresh session: %w", err) 436 } 437 defer resp.Body.Close() 438 439 if resp.StatusCode != http.StatusOK { 440 var errorResponse map[string]any 441 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 442 return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 443 } 444 errorBytes, _ := json.Marshal(errorResponse) 445 return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes)) 446 } 447 448 var refreshResponse struct { 449 AccessJwt string `json:"accessJwt"` 450 RefreshJwt string `json:"refreshJwt"` 451 } 452 if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil { 453 return fmt.Errorf("failed to decode refresh response: %w", err) 454 } 455 456 s.AccessJwt = refreshResponse.AccessJwt 457 s.RefreshJwt = refreshResponse.RefreshJwt 458 // Set new expiry time with 5 minute buffer 459 s.ExpiresAt = time.Now().Add(115 * time.Minute) 460 461 s.Logger.Debug("successfully refreshed app password session") 462 return nil 463} 464 465func (s *AppPasswordSession) IsValid() bool { 466 return time.Now().Before(s.ExpiresAt) 467} 468 469func (s *AppPasswordSession) putRecord(record any, collection string) error { 470 if !s.IsValid() { 471 s.Logger.Debug("access token expired, refreshing session") 472 if err := s.RefreshSession(); err != nil { 473 return fmt.Errorf("failed to refresh session: %w", err) 474 } 475 s.Logger.Debug("session refreshed") 476 } 477 478 recordBytes, err := json.Marshal(record) 479 if err != nil { 480 return fmt.Errorf("failed to marshal knot member record: %w", err) 481 } 482 483 payload := map[string]any{ 484 "repo": s.Did, 485 "collection": collection, 486 "rkey": tid.TID(), 487 "record": json.RawMessage(recordBytes), 488 } 489 490 payloadBytes, err := json.Marshal(payload) 491 if err != nil { 492 return fmt.Errorf("failed to marshal request payload: %w", err) 493 } 494 495 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 496 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 497 if err != nil { 498 return fmt.Errorf("failed to create HTTP request: %w", err) 499 } 500 501 req.Header.Set("Content-Type", "application/json") 502 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 503 504 s.Logger.Debug("putting record", "url", url, "collection", collection) 505 506 client := &http.Client{Timeout: 30 * time.Second} 507 resp, err := client.Do(req) 508 if err != nil { 509 return fmt.Errorf("failed to add user to default service: %w", err) 510 } 511 defer resp.Body.Close() 512 513 if resp.StatusCode != http.StatusOK { 514 var errorResponse map[string]any 515 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 516 return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 517 } 518 return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse) 519 } 520 521 return nil 522} 523 524// autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so, 525// ensures their corresponding sites domain is claimed. This is idempotent — 526// ClaimDomain is a no-op if the claim already exists. 527func (o *OAuth) autoClaimTnglShDomain(did string) { 528 l := o.Logger.With("did", did) 529 530 pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://") 531 pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 532 533 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 534 if err != nil { 535 l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err) 536 return 537 } 538 539 handle := resolved.Handle.String() 540 if !strings.HasSuffix(handle, "."+pdsDomain) { 541 return 542 } 543 544 if err := db.ClaimDomain(o.Db, did, handle); err != nil { 545 l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err) 546 } else { 547 l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle) 548 } 549} 550 551// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed. 552func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) { 553 o.appPasswordSessionMu.Lock() 554 defer o.appPasswordSessionMu.Unlock() 555 556 if o.appPasswordSession != nil { 557 return o.appPasswordSession, nil 558 } 559 560 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger) 561 if err != nil { 562 return nil, err 563 } 564 565 o.appPasswordSession = session 566 return session, nil 567}