A community based topic aggregation platform built on atproto
1package communities 2 3import ( 4 "Coves/internal/atproto/utils" 5 "bytes" 6 "context" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "log" 12 "net/http" 13 "regexp" 14 "strings" 15 "time" 16) 17 18// Community handle validation regex (DNS-valid handle: name.communities.instance.com) 19// Matches standard DNS hostname format (RFC 1035) 20var communityHandleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 21 22type communityService struct { 23 repo Repository 24 provisioner *PDSAccountProvisioner 25 pdsURL string 26 instanceDID string 27 instanceDomain string 28 pdsAccessToken string 29} 30 31// NewCommunityService creates a new community service 32func NewCommunityService(repo Repository, pdsURL, instanceDID, instanceDomain string, provisioner *PDSAccountProvisioner) Service { 33 // SECURITY: Basic validation that did:web domain matches configured instanceDomain 34 // This catches honest configuration mistakes but NOT malicious code modifications 35 // Full verification (Phase 2) requires fetching DID document from domain 36 // See: docs/PRD_BACKLOG.md - "did:web Domain Verification" 37 if strings.HasPrefix(instanceDID, "did:web:") { 38 didDomain := strings.TrimPrefix(instanceDID, "did:web:") 39 if didDomain != instanceDomain { 40 log.Printf("⚠️ SECURITY WARNING: Instance DID domain (%s) doesn't match configured domain (%s)", 41 didDomain, instanceDomain) 42 log.Printf(" This could indicate a configuration error or potential domain spoofing attempt") 43 log.Printf(" Communities will be hosted by: %s", instanceDID) 44 } 45 } 46 47 return &communityService{ 48 repo: repo, 49 pdsURL: pdsURL, 50 instanceDID: instanceDID, 51 instanceDomain: instanceDomain, 52 provisioner: provisioner, 53 } 54} 55 56// SetPDSAccessToken sets the PDS access token for authentication 57// This should be called after creating a session for the Coves instance DID on the PDS 58func (s *communityService) SetPDSAccessToken(token string) { 59 s.pdsAccessToken = token 60} 61 62// CreateCommunity creates a new community via write-forward to PDS 63// V2 Flow: 64// 1. Service creates PDS account for community (PDS generates signing keypair) 65// 2. Service writes community profile to COMMUNITY's own repository 66// 3. Firehose emits event 67// 4. Consumer indexes to AppView DB 68// 69// V2 Architecture: 70// - Community owns its own repository (at://community_did/social.coves.community.profile/self) 71// - PDS manages the signing keypair (we never see it) 72// - We store PDS credentials to act on behalf of the community 73// - Community can migrate to other instances (future V2.1 with rotation keys) 74func (s *communityService) CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error) { 75 // Apply defaults before validation 76 if req.Visibility == "" { 77 req.Visibility = "public" 78 } 79 80 // SECURITY: Auto-populate hostedByDID from instance configuration 81 // Clients MUST NOT provide this field - it's derived from the instance receiving the request 82 // This prevents malicious instances from claiming to host communities for domains they don't own 83 req.HostedByDID = s.instanceDID 84 85 // Validate request 86 if err := s.validateCreateRequest(req); err != nil { 87 return nil, err 88 } 89 90 // V2: Provision a real PDS account for this community 91 // This calls com.atproto.server.createAccount internally 92 // The PDS will: 93 // 1. Generate a signing keypair (stored in PDS, we never see it) 94 // 2. Create a DID (did:plc:xxx) 95 // 3. Return credentials (DID, tokens) 96 pdsAccount, err := s.provisioner.ProvisionCommunityAccount(ctx, req.Name) 97 if err != nil { 98 return nil, fmt.Errorf("failed to provision PDS account for community: %w", err) 99 } 100 101 // Validate the atProto handle 102 if validateErr := s.ValidateHandle(pdsAccount.Handle); validateErr != nil { 103 return nil, fmt.Errorf("generated atProto handle is invalid: %w", validateErr) 104 } 105 106 // Build community profile record 107 profile := map[string]interface{}{ 108 "$type": "social.coves.community.profile", 109 "handle": pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social) 110 "name": req.Name, // Short name for !mentions (e.g., "gaming") 111 "visibility": req.Visibility, 112 "hostedBy": s.instanceDID, // V2: Instance hosts, community owns 113 "createdBy": req.CreatedByDID, 114 "createdAt": time.Now().Format(time.RFC3339), 115 "federation": map[string]interface{}{ 116 "allowExternalDiscovery": req.AllowExternalDiscovery, 117 }, 118 } 119 120 // Add optional fields 121 if req.DisplayName != "" { 122 profile["displayName"] = req.DisplayName 123 } 124 if req.Description != "" { 125 profile["description"] = req.Description 126 } 127 if len(req.Rules) > 0 { 128 profile["rules"] = req.Rules 129 } 130 if len(req.Categories) > 0 { 131 profile["categories"] = req.Categories 132 } 133 if req.Language != "" { 134 profile["language"] = req.Language 135 } 136 137 // Initialize counts 138 profile["memberCount"] = 0 139 profile["subscriberCount"] = 0 140 141 // TODO: Handle avatar and banner blobs 142 // For now, we'll skip blob uploads. This would require: 143 // 1. Upload blob to PDS via com.atproto.repo.uploadBlob 144 // 2. Get blob ref (CID) 145 // 3. Add to profile record 146 147 // V2: Write to COMMUNITY's own repository (not instance repo!) 148 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self 149 // Authenticate using community's access token 150 recordURI, recordCID, err := s.createRecordOnPDSAs( 151 ctx, 152 pdsAccount.DID, // repo = community's DID (community owns its repo!) 153 "social.coves.community.profile", 154 "self", // canonical rkey for profile 155 profile, 156 pdsAccount.AccessToken, // authenticate as the community 157 ) 158 if err != nil { 159 return nil, fmt.Errorf("failed to create community profile record: %w", err) 160 } 161 162 // Build Community object with PDS credentials AND cryptographic keys 163 community := &Community{ 164 DID: pdsAccount.DID, // Community's DID (owns the repo!) 165 Handle: pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social) 166 Name: req.Name, 167 DisplayName: req.DisplayName, 168 Description: req.Description, 169 OwnerDID: pdsAccount.DID, // V2: Community owns itself 170 CreatedByDID: req.CreatedByDID, 171 HostedByDID: req.HostedByDID, 172 PDSEmail: pdsAccount.Email, 173 PDSPassword: pdsAccount.Password, 174 PDSAccessToken: pdsAccount.AccessToken, 175 PDSRefreshToken: pdsAccount.RefreshToken, 176 PDSURL: pdsAccount.PDSURL, 177 Visibility: req.Visibility, 178 AllowExternalDiscovery: req.AllowExternalDiscovery, 179 MemberCount: 0, 180 SubscriberCount: 0, 181 CreatedAt: time.Now(), 182 UpdatedAt: time.Now(), 183 RecordURI: recordURI, 184 RecordCID: recordCID, 185 // V2: Cryptographic keys for portability (will be encrypted by repository) 186 RotationKeyPEM: pdsAccount.RotationKeyPEM, // CRITICAL: Enables DID migration 187 SigningKeyPEM: pdsAccount.SigningKeyPEM, // For atproto operations 188 } 189 190 // CRITICAL: Persist PDS credentials immediately to database 191 // The Jetstream consumer will eventually index the community profile from the firehose, 192 // but it won't have the PDS credentials. We must store them now so we can: 193 // 1. Update the community profile later (using its own credentials) 194 // 2. Re-authenticate if access tokens expire 195 _, err = s.repo.Create(ctx, community) 196 if err != nil { 197 return nil, fmt.Errorf("failed to persist community with credentials: %w", err) 198 } 199 200 return community, nil 201} 202 203// GetCommunity retrieves a community from AppView DB 204// identifier can be either a DID or handle 205func (s *communityService) GetCommunity(ctx context.Context, identifier string) (*Community, error) { 206 if identifier == "" { 207 return nil, ErrInvalidInput 208 } 209 210 // Determine if identifier is DID or handle 211 if strings.HasPrefix(identifier, "did:") { 212 return s.repo.GetByDID(ctx, identifier) 213 } 214 215 if strings.HasPrefix(identifier, "!") { 216 return s.repo.GetByHandle(ctx, identifier) 217 } 218 219 return nil, NewValidationError("identifier", "must be a DID or handle") 220} 221 222// UpdateCommunity updates a community via write-forward to PDS 223func (s *communityService) UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error) { 224 if req.CommunityDID == "" { 225 return nil, NewValidationError("communityDid", "required") 226 } 227 228 if req.UpdatedByDID == "" { 229 return nil, NewValidationError("updatedByDid", "required") 230 } 231 232 // Get existing community 233 existing, err := s.repo.GetByDID(ctx, req.CommunityDID) 234 if err != nil { 235 return nil, err 236 } 237 238 // Authorization: verify user is the creator 239 // TODO(Communities-Auth): Add moderator check when moderation system is implemented 240 if existing.CreatedByDID != req.UpdatedByDID { 241 return nil, ErrUnauthorized 242 } 243 244 // Build updated profile record (start with existing) 245 profile := map[string]interface{}{ 246 "$type": "social.coves.community.profile", 247 "handle": existing.Handle, 248 "name": existing.Name, 249 "owner": existing.OwnerDID, 250 "createdBy": existing.CreatedByDID, 251 "hostedBy": existing.HostedByDID, 252 "createdAt": existing.CreatedAt.Format(time.RFC3339), 253 } 254 255 // Apply updates 256 if req.DisplayName != nil { 257 profile["displayName"] = *req.DisplayName 258 } else { 259 profile["displayName"] = existing.DisplayName 260 } 261 262 if req.Description != nil { 263 profile["description"] = *req.Description 264 } else { 265 profile["description"] = existing.Description 266 } 267 268 if req.Visibility != nil { 269 profile["visibility"] = *req.Visibility 270 } else { 271 profile["visibility"] = existing.Visibility 272 } 273 274 if req.AllowExternalDiscovery != nil { 275 profile["federation"] = map[string]interface{}{ 276 "allowExternalDiscovery": *req.AllowExternalDiscovery, 277 } 278 } else { 279 profile["federation"] = map[string]interface{}{ 280 "allowExternalDiscovery": existing.AllowExternalDiscovery, 281 } 282 } 283 284 // Preserve moderation settings (even if empty) 285 // These fields are optional but should not be erased on update 286 if req.ModerationType != nil { 287 profile["moderationType"] = *req.ModerationType 288 } else if existing.ModerationType != "" { 289 profile["moderationType"] = existing.ModerationType 290 } 291 292 if len(req.ContentWarnings) > 0 { 293 profile["contentWarnings"] = req.ContentWarnings 294 } else if len(existing.ContentWarnings) > 0 { 295 profile["contentWarnings"] = existing.ContentWarnings 296 } 297 298 // Preserve counts 299 profile["memberCount"] = existing.MemberCount 300 profile["subscriberCount"] = existing.SubscriberCount 301 302 // V2: Community profiles always use "self" as rkey 303 // (No need to extract from URI - it's always "self" for V2 communities) 304 305 // V2 CRITICAL FIX: Write-forward using COMMUNITY's own DID and credentials 306 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self 307 // Authenticate as the community (not as instance!) 308 if existing.PDSAccessToken == "" { 309 return nil, fmt.Errorf("community %s missing PDS credentials - cannot update", existing.DID) 310 } 311 312 recordURI, recordCID, err := s.putRecordOnPDSAs( 313 ctx, 314 existing.DID, // repo = community's own DID (V2!) 315 "social.coves.community.profile", 316 "self", // V2: always "self" 317 profile, 318 existing.PDSAccessToken, // authenticate as the community 319 ) 320 if err != nil { 321 return nil, fmt.Errorf("failed to update community on PDS: %w", err) 322 } 323 324 // Return updated community representation 325 // Actual AppView DB update happens via Jetstream consumer 326 updated := *existing 327 if req.DisplayName != nil { 328 updated.DisplayName = *req.DisplayName 329 } 330 if req.Description != nil { 331 updated.Description = *req.Description 332 } 333 if req.Visibility != nil { 334 updated.Visibility = *req.Visibility 335 } 336 if req.AllowExternalDiscovery != nil { 337 updated.AllowExternalDiscovery = *req.AllowExternalDiscovery 338 } 339 if req.ModerationType != nil { 340 updated.ModerationType = *req.ModerationType 341 } 342 if len(req.ContentWarnings) > 0 { 343 updated.ContentWarnings = req.ContentWarnings 344 } 345 updated.RecordURI = recordURI 346 updated.RecordCID = recordCID 347 updated.UpdatedAt = time.Now() 348 349 return &updated, nil 350} 351 352// ListCommunities queries AppView DB for communities with filters 353func (s *communityService) ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) { 354 // Set defaults 355 if req.Limit <= 0 || req.Limit > 100 { 356 req.Limit = 50 357 } 358 359 return s.repo.List(ctx, req) 360} 361 362// SearchCommunities performs fuzzy search in AppView DB 363func (s *communityService) SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error) { 364 if req.Query == "" { 365 return nil, 0, NewValidationError("query", "search query is required") 366 } 367 368 // Set defaults 369 if req.Limit <= 0 || req.Limit > 100 { 370 req.Limit = 50 371 } 372 373 return s.repo.Search(ctx, req) 374} 375 376// SubscribeToCommunity creates a subscription via write-forward to PDS 377func (s *communityService) SubscribeToCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string, contentVisibility int) (*Subscription, error) { 378 if userDID == "" { 379 return nil, NewValidationError("userDid", "required") 380 } 381 if userAccessToken == "" { 382 return nil, NewValidationError("userAccessToken", "required") 383 } 384 385 // Clamp contentVisibility to valid range (1-5), default to 3 if 0 or invalid 386 if contentVisibility <= 0 || contentVisibility > 5 { 387 contentVisibility = 3 388 } 389 390 // Resolve community identifier to DID 391 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 392 if err != nil { 393 return nil, err 394 } 395 396 // Verify community exists 397 community, err := s.repo.GetByDID(ctx, communityDID) 398 if err != nil { 399 return nil, err 400 } 401 402 // Check visibility - can't subscribe to private communities without invitation (TODO) 403 if community.Visibility == "private" { 404 return nil, ErrUnauthorized 405 } 406 407 // Build subscription record 408 // CRITICAL: Collection is social.coves.community.subscription (RECORD TYPE), not social.coves.community.subscribe (XRPC procedure) 409 // This record will be created in the USER's repository: at://user_did/social.coves.community.subscription/{tid} 410 // Following atProto conventions, we use "subject" field to reference the community 411 subRecord := map[string]interface{}{ 412 "$type": "social.coves.community.subscription", 413 "subject": communityDID, // atProto convention: "subject" for entity references 414 "createdAt": time.Now().Format(time.RFC3339), 415 "contentVisibility": contentVisibility, 416 } 417 418 // Write-forward: create subscription record in user's repo using their access token 419 // The collection parameter refers to the record type in the repository 420 recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, userDID, "social.coves.community.subscription", "", subRecord, userAccessToken) 421 if err != nil { 422 return nil, fmt.Errorf("failed to create subscription on PDS: %w", err) 423 } 424 425 // Return subscription representation 426 subscription := &Subscription{ 427 UserDID: userDID, 428 CommunityDID: communityDID, 429 ContentVisibility: contentVisibility, 430 SubscribedAt: time.Now(), 431 RecordURI: recordURI, 432 RecordCID: recordCID, 433 } 434 435 return subscription, nil 436} 437 438// UnsubscribeFromCommunity removes a subscription via PDS delete 439func (s *communityService) UnsubscribeFromCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error { 440 if userDID == "" { 441 return NewValidationError("userDid", "required") 442 } 443 if userAccessToken == "" { 444 return NewValidationError("userAccessToken", "required") 445 } 446 447 // Resolve community identifier 448 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 449 if err != nil { 450 return err 451 } 452 453 // Get the subscription from AppView to find the record key 454 subscription, err := s.repo.GetSubscription(ctx, userDID, communityDID) 455 if err != nil { 456 return err 457 } 458 459 // Extract rkey from record URI (at://did/collection/rkey) 460 rkey := utils.ExtractRKeyFromURI(subscription.RecordURI) 461 if rkey == "" { 462 return fmt.Errorf("invalid subscription record URI") 463 } 464 465 // Write-forward: delete record from PDS using user's access token 466 // CRITICAL: Delete from social.coves.community.subscription (RECORD TYPE), not social.coves.community.unsubscribe 467 if err := s.deleteRecordOnPDSAs(ctx, userDID, "social.coves.community.subscription", rkey, userAccessToken); err != nil { 468 return fmt.Errorf("failed to delete subscription on PDS: %w", err) 469 } 470 471 return nil 472} 473 474// GetUserSubscriptions queries AppView DB for user's subscriptions 475func (s *communityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error) { 476 if limit <= 0 || limit > 100 { 477 limit = 50 478 } 479 480 return s.repo.ListSubscriptions(ctx, userDID, limit, offset) 481} 482 483// GetCommunitySubscribers queries AppView DB for community subscribers 484func (s *communityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Subscription, error) { 485 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 486 if err != nil { 487 return nil, err 488 } 489 490 if limit <= 0 || limit > 100 { 491 limit = 50 492 } 493 494 return s.repo.ListSubscribers(ctx, communityDID, limit, offset) 495} 496 497// GetMembership retrieves membership info from AppView DB 498func (s *communityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*Membership, error) { 499 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 500 if err != nil { 501 return nil, err 502 } 503 504 return s.repo.GetMembership(ctx, userDID, communityDID) 505} 506 507// ListCommunityMembers queries AppView DB for members 508func (s *communityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Membership, error) { 509 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 510 if err != nil { 511 return nil, err 512 } 513 514 if limit <= 0 || limit > 100 { 515 limit = 50 516 } 517 518 return s.repo.ListMembers(ctx, communityDID, limit, offset) 519} 520 521// BlockCommunity blocks a community via write-forward to PDS 522func (s *communityService) BlockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) (*CommunityBlock, error) { 523 if userDID == "" { 524 return nil, NewValidationError("userDid", "required") 525 } 526 if userAccessToken == "" { 527 return nil, NewValidationError("userAccessToken", "required") 528 } 529 530 // Resolve community identifier (also verifies community exists) 531 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 532 if err != nil { 533 return nil, err 534 } 535 536 // Build block record 537 // CRITICAL: Collection is social.coves.community.block (RECORD TYPE) 538 // This record will be created in the USER's repository: at://user_did/social.coves.community.block/{tid} 539 // Following atProto conventions and Bluesky's app.bsky.graph.block pattern 540 blockRecord := map[string]interface{}{ 541 "$type": "social.coves.community.block", 542 "subject": communityDID, // DID of community being blocked 543 "createdAt": time.Now().Format(time.RFC3339), 544 } 545 546 // Write-forward: create block record in user's repo using their access token 547 // Note: We don't check for existing blocks first because: 548 // 1. The PDS may reject duplicates (depending on implementation) 549 // 2. The repository layer handles idempotency with ON CONFLICT DO NOTHING 550 // 3. This avoids a race condition where two concurrent requests both pass the check 551 recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, userDID, "social.coves.community.block", "", blockRecord, userAccessToken) 552 if err != nil { 553 // Check if this is a duplicate/conflict error from PDS 554 // PDS should return 409 Conflict for duplicate records, but we also check common error messages 555 // for compatibility with different PDS implementations 556 errMsg := err.Error() 557 isDuplicate := strings.Contains(errMsg, "status 409") || // HTTP 409 Conflict 558 strings.Contains(errMsg, "duplicate") || 559 strings.Contains(errMsg, "already exists") || 560 strings.Contains(errMsg, "AlreadyExists") 561 562 if isDuplicate { 563 // Fetch and return existing block from our indexed view 564 existingBlock, getErr := s.repo.GetBlock(ctx, userDID, communityDID) 565 if getErr == nil { 566 // Block exists in our index - return it 567 return existingBlock, nil 568 } 569 // Only treat as "already exists" if the error is ErrBlockNotFound (race condition) 570 // Any other error (DB outage, connection failure, etc.) should bubble up 571 if errors.Is(getErr, ErrBlockNotFound) { 572 // Race condition: PDS has the block but Jetstream hasn't indexed it yet 573 // Return typed conflict error so handler can return 409 instead of 500 574 // This is normal in eventually-consistent systems 575 return nil, ErrBlockAlreadyExists 576 } 577 // Real datastore error - bubble it up so operators see the failure 578 return nil, fmt.Errorf("PDS reported duplicate block but failed to fetch from index: %w", getErr) 579 } 580 return nil, fmt.Errorf("failed to create block on PDS: %w", err) 581 } 582 583 // Return block representation 584 block := &CommunityBlock{ 585 UserDID: userDID, 586 CommunityDID: communityDID, 587 BlockedAt: time.Now(), 588 RecordURI: recordURI, 589 RecordCID: recordCID, 590 } 591 592 return block, nil 593} 594 595// UnblockCommunity removes a block via PDS delete 596func (s *communityService) UnblockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error { 597 if userDID == "" { 598 return NewValidationError("userDid", "required") 599 } 600 if userAccessToken == "" { 601 return NewValidationError("userAccessToken", "required") 602 } 603 604 // Resolve community identifier 605 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 606 if err != nil { 607 return err 608 } 609 610 // Get the block from AppView to find the record key 611 block, err := s.repo.GetBlock(ctx, userDID, communityDID) 612 if err != nil { 613 return err 614 } 615 616 // Extract rkey from record URI (at://did/collection/rkey) 617 rkey := utils.ExtractRKeyFromURI(block.RecordURI) 618 if rkey == "" { 619 return fmt.Errorf("invalid block record URI") 620 } 621 622 // Write-forward: delete record from PDS using user's access token 623 if err := s.deleteRecordOnPDSAs(ctx, userDID, "social.coves.community.block", rkey, userAccessToken); err != nil { 624 return fmt.Errorf("failed to delete block on PDS: %w", err) 625 } 626 627 return nil 628} 629 630// GetBlockedCommunities queries AppView DB for user's blocks 631func (s *communityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*CommunityBlock, error) { 632 if limit <= 0 || limit > 100 { 633 limit = 50 634 } 635 636 return s.repo.ListBlockedCommunities(ctx, userDID, limit, offset) 637} 638 639// IsBlocked checks if a user has blocked a community 640func (s *communityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 641 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 642 if err != nil { 643 return false, err 644 } 645 646 return s.repo.IsBlocked(ctx, userDID, communityDID) 647} 648 649// ValidateHandle checks if a community handle is valid 650func (s *communityService) ValidateHandle(handle string) error { 651 if handle == "" { 652 return NewValidationError("handle", "required") 653 } 654 655 if !communityHandleRegex.MatchString(handle) { 656 return ErrInvalidHandle 657 } 658 659 return nil 660} 661 662// ResolveCommunityIdentifier converts a handle or DID to a DID 663func (s *communityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 664 if identifier == "" { 665 return "", ErrInvalidInput 666 } 667 668 // If it's already a DID, verify the community exists 669 if strings.HasPrefix(identifier, "did:") { 670 _, err := s.repo.GetByDID(ctx, identifier) 671 if err != nil { 672 if IsNotFound(err) { 673 return "", fmt.Errorf("community not found: %w", err) 674 } 675 return "", fmt.Errorf("failed to verify community DID: %w", err) 676 } 677 return identifier, nil 678 } 679 680 // If it's a handle, look it up in AppView DB 681 if strings.HasPrefix(identifier, "!") { 682 community, err := s.repo.GetByHandle(ctx, identifier) 683 if err != nil { 684 return "", err 685 } 686 return community.DID, nil 687 } 688 689 return "", NewValidationError("identifier", "must be a DID or handle") 690} 691 692// Validation helpers 693 694func (s *communityService) validateCreateRequest(req CreateCommunityRequest) error { 695 if req.Name == "" { 696 return NewValidationError("name", "required") 697 } 698 699 // DNS label limit: 63 characters per label 700 // Community handle format: {name}.communities.{instanceDomain} 701 // The first label is just req.Name, so it must be <= 63 chars 702 if len(req.Name) > 63 { 703 return NewValidationError("name", "must be 63 characters or less (DNS label limit)") 704 } 705 706 // Name can only contain alphanumeric and hyphens 707 // Must start and end with alphanumeric (not hyphen) 708 nameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 709 if !nameRegex.MatchString(req.Name) { 710 return NewValidationError("name", "must contain only alphanumeric characters and hyphens") 711 } 712 713 if req.Description != "" && len(req.Description) > 3000 { 714 return NewValidationError("description", "must be 3000 characters or less") 715 } 716 717 // Visibility should already be set with default in CreateCommunity 718 if req.Visibility != "public" && req.Visibility != "unlisted" && req.Visibility != "private" { 719 return ErrInvalidVisibility 720 } 721 722 if req.CreatedByDID == "" { 723 return NewValidationError("createdByDid", "required") 724 } 725 726 // hostedByDID is auto-populated by the service layer, no validation needed 727 // The handler ensures clients cannot provide this field 728 729 return nil 730} 731 732// PDS write-forward helpers 733 734// createRecordOnPDSAs creates a record with a specific access token (for V2 community auth) 735func (s *communityService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) { 736 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/")) 737 738 payload := map[string]interface{}{ 739 "repo": repoDID, 740 "collection": collection, 741 "record": record, 742 } 743 744 if rkey != "" { 745 payload["rkey"] = rkey 746 } 747 748 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 749} 750 751// putRecordOnPDSAs updates a record with a specific access token (for V2 community auth) 752func (s *communityService) putRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) { 753 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", strings.TrimSuffix(s.pdsURL, "/")) 754 755 payload := map[string]interface{}{ 756 "repo": repoDID, 757 "collection": collection, 758 "rkey": rkey, 759 "record": record, 760 } 761 762 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 763} 764 765// deleteRecordOnPDSAs deletes a record with a specific access token (for user-scoped deletions) 766func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error { 767 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/")) 768 769 payload := map[string]interface{}{ 770 "repo": repoDID, 771 "collection": collection, 772 "rkey": rkey, 773 } 774 775 _, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 776 return err 777} 778 779// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication) 780func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) { 781 jsonData, err := json.Marshal(payload) 782 if err != nil { 783 return "", "", fmt.Errorf("failed to marshal payload: %w", err) 784 } 785 786 req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewBuffer(jsonData)) 787 if err != nil { 788 return "", "", fmt.Errorf("failed to create request: %w", err) 789 } 790 req.Header.Set("Content-Type", "application/json") 791 792 // Add authentication with provided access token 793 if accessToken != "" { 794 req.Header.Set("Authorization", "Bearer "+accessToken) 795 } 796 797 // Dynamic timeout based on operation type 798 // Write operations (createAccount, createRecord, putRecord) are slower due to: 799 // - Keypair generation 800 // - DID PLC registration 801 // - Database writes on PDS 802 timeout := 10 * time.Second // Default for read operations 803 if strings.Contains(endpoint, "createAccount") || 804 strings.Contains(endpoint, "createRecord") || 805 strings.Contains(endpoint, "putRecord") { 806 timeout = 30 * time.Second // Extended timeout for write operations 807 } 808 809 client := &http.Client{Timeout: timeout} 810 resp, err := client.Do(req) 811 if err != nil { 812 return "", "", fmt.Errorf("failed to call PDS: %w", err) 813 } 814 defer func() { 815 if closeErr := resp.Body.Close(); closeErr != nil { 816 log.Printf("Failed to close response body: %v", closeErr) 817 } 818 }() 819 820 body, err := io.ReadAll(resp.Body) 821 if err != nil { 822 return "", "", fmt.Errorf("failed to read response: %w", err) 823 } 824 825 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 826 return "", "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) 827 } 828 829 // Parse response to extract URI and CID 830 var result struct { 831 URI string `json:"uri"` 832 CID string `json:"cid"` 833 } 834 if err := json.Unmarshal(body, &result); err != nil { 835 // For delete operations, there might not be a response body 836 if method == "POST" && strings.Contains(endpoint, "deleteRecord") { 837 return "", "", nil 838 } 839 return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 840 } 841 842 return result.URI, result.CID, nil 843} 844 845// Helper functions