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