A community based topic aggregation platform built on atproto
at main 843 lines 31 kB view raw
1package posts 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "os" 13 "strings" 14 "time" 15 16 "Coves/internal/api/middleware" 17 "Coves/internal/atproto/pds" 18 "Coves/internal/atproto/utils" 19 "Coves/internal/core/aggregators" 20 "Coves/internal/core/blobs" 21 "Coves/internal/core/blueskypost" 22 "Coves/internal/core/communities" 23 "Coves/internal/core/unfurl" 24 25 "github.com/bluesky-social/indigo/atproto/auth/oauth" 26) 27 28type postService struct { 29 repo Repository 30 communityService communities.Service 31 aggregatorService aggregators.Service 32 blobService blobs.Service 33 unfurlService unfurl.Service 34 blueskyService blueskypost.Service 35 pdsURL string 36} 37 38// NewPostService creates a new post service 39// aggregatorService, blobService, unfurlService, and blueskyService can be nil if not needed (e.g., in tests or minimal setups) 40func NewPostService( 41 repo Repository, 42 communityService communities.Service, 43 aggregatorService aggregators.Service, // Optional: can be nil 44 blobService blobs.Service, // Optional: can be nil 45 unfurlService unfurl.Service, // Optional: can be nil 46 blueskyService blueskypost.Service, // Optional: can be nil 47 pdsURL string, 48) Service { 49 return &postService{ 50 repo: repo, 51 communityService: communityService, 52 aggregatorService: aggregatorService, 53 blobService: blobService, 54 unfurlService: unfurlService, 55 blueskyService: blueskyService, 56 pdsURL: pdsURL, 57 } 58} 59 60// CreatePost creates a new post in a community 61// Flow: 62// 1. Validate input 63// 2. Check if author is an aggregator (server-side validation using DID from JWT) 64// 3. If aggregator: validate authorization and rate limits, skip membership checks 65// 4. If user: resolve community and perform membership/ban validation 66// 5. Build post record 67// 6. Write to community's PDS repository 68// 7. If aggregator: record post for rate limiting 69// 8. Return URI/CID (AppView indexes asynchronously via Jetstream) 70func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) { 71 // 1. Validate basic input (before DID checks to give clear validation errors) 72 if err := s.validateCreateRequest(&req); err != nil { 73 return nil, err 74 } 75 76 // 2. SECURITY: Extract authenticated DID from context (set by JWT middleware) 77 // Defense-in-depth: verify service layer receives correct DID even if handler is bypassed 78 authenticatedDID := middleware.GetAuthenticatedDID(ctx) 79 if authenticatedDID == "" { 80 return nil, fmt.Errorf("no authenticated DID in context - authentication required") 81 } 82 83 // SECURITY: Verify request DID matches authenticated DID from JWT 84 // This prevents DID spoofing where a malicious client or compromised handler 85 // could provide a different DID than what was authenticated 86 if authenticatedDID != req.AuthorDID { 87 log.Printf("[SECURITY] DID mismatch: authenticated=%s, request=%s", authenticatedDID, req.AuthorDID) 88 return nil, fmt.Errorf("authenticated DID does not match author DID") 89 } 90 91 // 3. Determine actor type: trusted aggregator, other aggregator, or regular user 92 // Check against comma-separated list of trusted aggregator DIDs 93 trustedDIDs := os.Getenv("TRUSTED_AGGREGATOR_DIDS") 94 if trustedDIDs == "" { 95 // Fallback to legacy single DID env var 96 trustedDIDs = os.Getenv("KAGI_AGGREGATOR_DID") 97 } 98 isTrustedAggregator := false 99 if trustedDIDs != "" { 100 for _, did := range strings.Split(trustedDIDs, ",") { 101 if strings.TrimSpace(did) == req.AuthorDID { 102 isTrustedAggregator = true 103 break 104 } 105 } 106 } 107 108 // Check if this is a non-trusted aggregator (requires database lookup) 109 var isOtherAggregator bool 110 var err error 111 if !isTrustedAggregator && s.aggregatorService != nil { 112 isOtherAggregator, err = s.aggregatorService.IsAggregator(ctx, req.AuthorDID) 113 if err != nil { 114 log.Printf("[POST-CREATE] Warning: failed to check if DID is aggregator: %v", err) 115 // Don't fail the request - treat as regular user if check fails 116 isOtherAggregator = false 117 } 118 } 119 120 // 4. Resolve community at-identifier (handle or DID) to DID 121 // This accepts both formats per atProto best practices: 122 // - Handles: !gardening.communities.coves.social 123 // - DIDs: did:plc:abc123 or did:web:coves.social 124 communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community) 125 if err != nil { 126 // Handle specific error types appropriately 127 if communities.IsNotFound(err) { 128 return nil, ErrCommunityNotFound 129 } 130 if communities.IsValidationError(err) { 131 // Pass through validation errors (invalid format, etc.) 132 return nil, NewValidationError("community", err.Error()) 133 } 134 // Infrastructure failures (DB errors, network issues) should be internal errors 135 // Don't leak internal details to client (e.g., "pq: connection refused") 136 return nil, fmt.Errorf("failed to resolve community identifier: %w", err) 137 } 138 139 // 5. AUTHORIZATION: For non-Kagi aggregators, validate authorization and rate limits 140 // Kagi is exempted from database checks via env var (temporary until XRPC endpoint is ready) 141 if isOtherAggregator && s.aggregatorService != nil { 142 if err := s.aggregatorService.ValidateAggregatorPost(ctx, req.AuthorDID, communityDID); err != nil { 143 log.Printf("[POST-CREATE] Aggregator authorization failed: %s -> %s: %v", req.AuthorDID, communityDID, err) 144 return nil, fmt.Errorf("aggregator not authorized: %w", err) 145 } 146 log.Printf("[POST-CREATE] Aggregator authorized: %s -> %s", req.AuthorDID, communityDID) 147 } 148 149 // 6. Fetch community from AppView (includes all metadata) 150 community, err := s.communityService.GetByDID(ctx, communityDID) 151 if err != nil { 152 if communities.IsNotFound(err) { 153 return nil, ErrCommunityNotFound 154 } 155 return nil, fmt.Errorf("failed to fetch community: %w", err) 156 } 157 158 // 7. Apply validation based on actor type (aggregator vs user) 159 if isTrustedAggregator { 160 // TRUSTED AGGREGATOR VALIDATION FLOW 161 // Trusted aggregators are authorized via TRUSTED_AGGREGATOR_DIDS env var (temporary) 162 // TODO: Replace with proper XRPC aggregator authorization endpoint 163 log.Printf("[POST-CREATE] Trusted aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID) 164 // Aggregators skip membership checks and visibility restrictions 165 // They are authorized services, not community members 166 } else if isOtherAggregator { 167 // OTHER AGGREGATOR VALIDATION FLOW 168 // Authorization and rate limits already validated above via ValidateAggregatorPost 169 log.Printf("[POST-CREATE] Authorized aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID) 170 } else { 171 // USER VALIDATION FLOW 172 // Check community visibility (Alpha: public/unlisted only) 173 // Beta will add membership checks for private communities 174 if community.Visibility == "private" { 175 return nil, ErrNotAuthorized 176 } 177 } 178 179 // 8. Ensure community has fresh PDS credentials (token refresh if needed) 180 community, err = s.communityService.EnsureFreshToken(ctx, community) 181 if err != nil { 182 return nil, fmt.Errorf("failed to refresh community credentials: %w", err) 183 } 184 185 // 9. Build post record for PDS 186 postRecord := PostRecord{ 187 Type: "social.coves.community.post", 188 Community: communityDID, 189 Author: req.AuthorDID, 190 Title: req.Title, 191 Content: req.Content, 192 Facets: req.Facets, 193 Embed: req.Embed, // Start with user-provided embed 194 Labels: req.Labels, 195 OriginalAuthor: req.OriginalAuthor, 196 FederatedFrom: req.FederatedFrom, 197 Location: req.Location, 198 CreatedAt: time.Now().UTC().Format(time.RFC3339), 199 } 200 201 // 10. Validate and enhance external embeds 202 if postRecord.Embed != nil { 203 embedType, typeOk := postRecord.Embed["$type"].(string) 204 if typeOk && embedType == "social.coves.embed.external" { 205 if external, extOk := postRecord.Embed["external"].(map[string]interface{}); extOk { 206 // Check if this is a Bluesky post URL and convert to post embed 207 if !s.tryConvertBlueskyURLToPostEmbed(ctx, external, &postRecord) { 208 // Not a Bluesky URL or conversion failed - continue with normal external embed processing 209 // SECURITY: Validate thumb field (must be blob, not URL string) 210 // This validation happens BEFORE unfurl to catch client errors early 211 if existingThumb := external["thumb"]; existingThumb != nil { 212 if thumbStr, isString := existingThumb.(string); isString { 213 return nil, NewValidationError("thumb", 214 fmt.Sprintf("thumb must be a blob reference (with $type, ref, mimeType, size), not URL string: %s", thumbStr)) 215 } 216 217 // Validate blob structure if provided 218 if thumbMap, isMap := existingThumb.(map[string]interface{}); isMap { 219 // Check for $type field 220 if thumbType, ok := thumbMap["$type"].(string); !ok || thumbType != "blob" { 221 return nil, NewValidationError("thumb", 222 fmt.Sprintf("thumb must have $type: blob (got: %v)", thumbType)) 223 } 224 // Check for required blob fields 225 if _, hasRef := thumbMap["ref"]; !hasRef { 226 return nil, NewValidationError("thumb", "thumb blob missing required 'ref' field") 227 } 228 if _, hasMimeType := thumbMap["mimeType"]; !hasMimeType { 229 return nil, NewValidationError("thumb", "thumb blob missing required 'mimeType' field") 230 } 231 log.Printf("[POST-CREATE] Client provided valid thumbnail blob") 232 } else { 233 return nil, NewValidationError("thumb", 234 fmt.Sprintf("thumb must be a blob object, got: %T", existingThumb)) 235 } 236 } 237 238 // TRUSTED AGGREGATOR: Allow Kagi aggregator to provide thumbnail URLs directly 239 // This bypasses unfurl for more accurate RSS-sourced thumbnails 240 if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedAggregator { 241 log.Printf("[AGGREGATOR-THUMB] Trusted aggregator provided thumbnail: %s", *req.ThumbnailURL) 242 243 if s.blobService != nil { 244 blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second) 245 defer blobCancel() 246 247 blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, *req.ThumbnailURL) 248 if blobErr != nil { 249 log.Printf("[AGGREGATOR-THUMB] Failed to upload thumbnail: %v", blobErr) 250 // No fallback - aggregators only use RSS feed thumbnails 251 } else { 252 external["thumb"] = blob 253 log.Printf("[AGGREGATOR-THUMB] Successfully uploaded thumbnail from trusted aggregator") 254 } 255 } 256 } 257 258 // Unfurl enhancement (optional, only if URL is supported) 259 // For trusted aggregators: only unfurl for thumbnail if they didn't provide one 260 // For regular users: full unfurl for all metadata 261 needsThumbnailUnfurl := isTrustedAggregator && external["thumb"] == nil && (req.ThumbnailURL == nil || *req.ThumbnailURL == "") 262 needsFullUnfurl := !isTrustedAggregator 263 264 if needsThumbnailUnfurl || needsFullUnfurl { 265 if uri, ok := external["uri"].(string); ok && uri != "" { 266 // Check if we support unfurling this URL 267 if s.unfurlService != nil && s.unfurlService.IsSupported(uri) { 268 log.Printf("[POST-CREATE] Unfurling URL: %s (thumbnailOnly=%v)", uri, needsThumbnailUnfurl) 269 270 // Unfurl with timeout (non-fatal if it fails) 271 unfurlCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 272 defer cancel() 273 274 result, err := s.unfurlService.UnfurlURL(unfurlCtx, uri) 275 if err != nil { 276 // Log but don't fail - user can still post with manual metadata 277 log.Printf("[POST-CREATE] Warning: Failed to unfurl URL %s: %v", uri, err) 278 } else { 279 // For regular users: enhance embed with fetched metadata 280 // For trusted aggregators: skip metadata, they provide their own 281 if needsFullUnfurl { 282 // Enhance embed with fetched metadata (only if client didn't provide) 283 // Note: We respect client-provided values, even empty strings 284 // If client sends title="", we assume they want no title 285 if external["title"] == nil { 286 external["title"] = result.Title 287 } 288 if external["description"] == nil { 289 external["description"] = result.Description 290 } 291 // Always set metadata fields (provider, domain, type) 292 external["embedType"] = result.Type 293 external["provider"] = result.Provider 294 external["domain"] = result.Domain 295 } 296 297 // Upload thumbnail from unfurl if client didn't provide one 298 // (Thumb validation already happened above) 299 if external["thumb"] == nil { 300 if result.ThumbnailURL != "" && s.blobService != nil { 301 blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second) 302 defer blobCancel() 303 304 blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, result.ThumbnailURL) 305 if blobErr != nil { 306 log.Printf("[POST-CREATE] Warning: Failed to upload thumbnail for %s: %v", uri, blobErr) 307 } else { 308 external["thumb"] = blob 309 log.Printf("[POST-CREATE] Uploaded thumbnail blob for %s", uri) 310 } 311 } 312 } 313 314 if needsFullUnfurl { 315 log.Printf("[POST-CREATE] Successfully enhanced embed with unfurl data (provider: %s, type: %s)", 316 result.Provider, result.Type) 317 } else { 318 log.Printf("[POST-CREATE] Fetched thumbnail via unfurl for trusted aggregator") 319 } 320 } 321 } 322 } 323 } 324 } 325 } 326 } 327 } 328 329 // 11. Write to community's PDS repository 330 uri, cid, err := s.createPostOnPDS(ctx, community, postRecord) 331 if err != nil { 332 return nil, fmt.Errorf("failed to write post to PDS: %w", err) 333 } 334 335 // 12. Record aggregator post for rate limiting (non-Kagi aggregators only) 336 // Kagi is exempted from rate limiting via env var (temporary) 337 if isOtherAggregator && s.aggregatorService != nil { 338 if recordErr := s.aggregatorService.RecordAggregatorPost(ctx, req.AuthorDID, communityDID, uri, cid); recordErr != nil { 339 // Log but don't fail - post was already created successfully 340 log.Printf("[POST-CREATE] Warning: failed to record aggregator post for rate limiting: %v", recordErr) 341 } 342 } 343 344 // 13. Return response (AppView will index via Jetstream consumer) 345 log.Printf("[POST-CREATE] Author: %s (trustedKagi=%v, otherAggregator=%v), Community: %s, URI: %s", 346 req.AuthorDID, isTrustedAggregator, isOtherAggregator, communityDID, uri) 347 348 return &CreatePostResponse{ 349 URI: uri, 350 CID: cid, 351 }, nil 352} 353 354// validateCreateRequest validates basic input requirements 355func (s *postService) validateCreateRequest(req *CreatePostRequest) error { 356 // Global content limits (from lexicon) 357 const ( 358 maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon 359 maxTitleLength = 3000 // 3k bytes 360 maxTitleGraphemes = 300 // 300 graphemes (simplified check) 361 ) 362 363 // Validate community required 364 if req.Community == "" { 365 return NewValidationError("community", "community is required") 366 } 367 368 // Validate author DID set by handler 369 if req.AuthorDID == "" { 370 return NewValidationError("authorDid", "authorDid must be set from authenticated user") 371 } 372 373 // Validate content length 374 if req.Content != nil && len(*req.Content) > maxContentLength { 375 return NewValidationError("content", 376 fmt.Sprintf("content too long (max %d characters)", maxContentLength)) 377 } 378 379 // Validate title length 380 if req.Title != nil { 381 if len(*req.Title) > maxTitleLength { 382 return NewValidationError("title", 383 fmt.Sprintf("title too long (max %d bytes)", maxTitleLength)) 384 } 385 // Simplified grapheme check (actual implementation would need unicode library) 386 // For Alpha, byte length check is sufficient 387 } 388 389 // Validate content labels are from known values 390 if req.Labels != nil { 391 validLabels := map[string]bool{ 392 "nsfw": true, 393 "spoiler": true, 394 "violence": true, 395 } 396 for _, label := range req.Labels.Values { 397 if !validLabels[label.Val] { 398 return NewValidationError("labels", 399 fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val)) 400 } 401 } 402 } 403 404 return nil 405} 406 407// createPostOnPDS writes a post record to the community's PDS repository 408// Uses com.atproto.repo.createRecord endpoint 409func (s *postService) createPostOnPDS( 410 ctx context.Context, 411 community *communities.Community, 412 record PostRecord, 413) (uri, cid string, err error) { 414 // Use community's PDS URL (not service default) for federated communities 415 // Each community can be hosted on a different PDS instance 416 pdsURL := community.PDSURL 417 if pdsURL == "" { 418 // Fallback to service default if community doesn't have a PDS URL 419 // (shouldn't happen in practice, but safe default) 420 pdsURL = s.pdsURL 421 } 422 423 // Build PDS endpoint URL 424 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL) 425 426 // Build request payload 427 // IMPORTANT: repo is set to community DID, not author DID 428 // This writes the post to the community's repository 429 payload := map[string]interface{}{ 430 "repo": community.DID, // Community's repository 431 "collection": "social.coves.community.post", // Collection type 432 "record": record, // The post record 433 // "rkey" omitted - PDS will auto-generate TID 434 } 435 436 // Marshal payload 437 jsonData, err := json.Marshal(payload) 438 if err != nil { 439 return "", "", fmt.Errorf("failed to marshal post payload: %w", err) 440 } 441 442 // Create HTTP request 443 req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 444 if err != nil { 445 return "", "", fmt.Errorf("failed to create PDS request: %w", err) 446 } 447 448 // Set headers (auth + content type) 449 req.Header.Set("Content-Type", "application/json") 450 req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken) 451 452 // Extended timeout for write operations (30 seconds) 453 client := &http.Client{ 454 Timeout: 30 * time.Second, 455 } 456 457 // Execute request 458 resp, err := client.Do(req) 459 if err != nil { 460 return "", "", fmt.Errorf("PDS request failed: %w", err) 461 } 462 defer func() { 463 if closeErr := resp.Body.Close(); closeErr != nil { 464 log.Printf("Warning: failed to close response body: %v", closeErr) 465 } 466 }() 467 468 // Read response body 469 body, err := io.ReadAll(resp.Body) 470 if err != nil { 471 return "", "", fmt.Errorf("failed to read PDS response: %w", err) 472 } 473 474 // Check for errors 475 if resp.StatusCode != http.StatusOK { 476 // Sanitize error body for logging (prevent sensitive data leakage) 477 bodyPreview := string(body) 478 if len(bodyPreview) > 200 { 479 bodyPreview = bodyPreview[:200] + "... (truncated)" 480 } 481 log.Printf("[POST-CREATE-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview) 482 483 // Return truncated error (defense in depth - handler will mask this further) 484 return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview) 485 } 486 487 // Parse response 488 var result struct { 489 URI string `json:"uri"` 490 CID string `json:"cid"` 491 } 492 if err := json.Unmarshal(body, &result); err != nil { 493 return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 494 } 495 496 return result.URI, result.CID, nil 497} 498 499// tryConvertBlueskyURLToPostEmbed attempts to convert a Bluesky URL in an external embed to a post embed. 500// Returns true if the conversion was successful and the postRecord was modified. 501// Returns false if the URL is not a Bluesky URL or if conversion failed (caller should continue with external embed). 502// 503// A strongRef is an AT Protocol reference containing both URI (at://did/collection/rkey) and CID 504// (content identifier hash). This function resolves the Bluesky URL to obtain both values, 505// enabling rich embedded quote posts instead of plain external links. 506func (s *postService) tryConvertBlueskyURLToPostEmbed(ctx context.Context, external map[string]interface{}, postRecord *PostRecord) bool { 507 // 1. Check if blueskyService is available 508 if s.blueskyService == nil { 509 log.Printf("[POST-CREATE] BlueskyService unavailable, keeping as external embed") 510 return false 511 } 512 513 // 2. Extract and validate URL 514 url, ok := external["uri"].(string) 515 if !ok || url == "" { 516 return false 517 } 518 519 // 3. Check if it's a Bluesky URL 520 if !s.blueskyService.IsBlueskyURL(url) { 521 return false 522 } 523 524 // 4. Parse URL to AT-URI (resolves handle to DID if needed) 525 atURI, err := s.blueskyService.ParseBlueskyURL(ctx, url) 526 if err != nil { 527 // Differentiate between timeout and other errors 528 if errors.Is(err, context.DeadlineExceeded) { 529 log.Printf("[POST-CREATE] WARN: Bluesky URL parse timed out, keeping as external embed: %s", url) 530 } else { 531 log.Printf("[POST-CREATE] Failed to parse Bluesky URL %s: %v", url, err) 532 } 533 return false // Fall back to external embed 534 } 535 536 // 5. Resolve post to get CID 537 result, err := s.blueskyService.ResolvePost(ctx, atURI) 538 if err != nil { 539 // Differentiate error types for better debugging 540 if errors.Is(err, blueskypost.ErrCircuitOpen) { 541 log.Printf("[POST-CREATE] WARN: Bluesky circuit breaker OPEN, keeping as external embed: %s", atURI) 542 } else { 543 log.Printf("[POST-CREATE] Failed to resolve Bluesky post %s: %v", atURI, err) 544 } 545 return false // Fall back to external embed 546 } 547 548 if result == nil { 549 log.Printf("[POST-CREATE] ERROR: ResolvePost returned nil result for %s", atURI) 550 return false 551 } 552 553 // 6. Handle unavailable posts - keep as external embed since we can't get a valid CID 554 if result.Unavailable { 555 log.Printf("[POST-CREATE] Bluesky post unavailable, keeping as external embed: %s (reason: %s)", atURI, result.Message) 556 return false 557 } 558 559 // 7. Validate we have both URI and CID 560 if result.URI == "" || result.CID == "" { 561 log.Printf("[POST-CREATE] ERROR: Bluesky post missing URI or CID (internal bug): uri=%q, cid=%q", result.URI, result.CID) 562 return false 563 } 564 565 // 8. Convert embed to social.coves.embed.post with strongRef 566 postRecord.Embed = map[string]interface{}{ 567 "$type": "social.coves.embed.post", 568 "post": map[string]interface{}{ 569 "uri": result.URI, 570 "cid": result.CID, 571 }, 572 } 573 574 log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID) 575 return true 576} 577 578// GetAuthorPosts retrieves posts by a specific author with optional filtering 579// Supports filtering by: posts_with_replies, posts_no_replies, posts_with_media 580// Optionally filter to a specific community 581func (s *postService) GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) { 582 // 1. Validate request 583 if err := s.validateGetAuthorPostsRequest(&req); err != nil { 584 return nil, err 585 } 586 587 // 2. If community is provided, resolve it to DID 588 if req.Community != "" { 589 communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community) 590 if err != nil { 591 if communities.IsNotFound(err) { 592 return nil, ErrCommunityNotFound 593 } 594 if communities.IsValidationError(err) { 595 return nil, NewValidationError("community", err.Error()) 596 } 597 return nil, fmt.Errorf("failed to resolve community identifier: %w", err) 598 } 599 req.Community = communityDID 600 } 601 602 // 3. Fetch posts from repository 603 postViews, cursor, err := s.repo.GetByAuthor(ctx, req) 604 if err != nil { 605 return nil, fmt.Errorf("failed to get author posts: %w", err) 606 } 607 608 // 4. Wrap PostViews in FeedViewPost 609 feed := make([]*FeedViewPost, len(postViews)) 610 for i, postView := range postViews { 611 feed[i] = &FeedViewPost{ 612 Post: postView, 613 } 614 } 615 616 // 5. Return response 617 return &GetAuthorPostsResponse{ 618 Feed: feed, 619 Cursor: cursor, 620 }, nil 621} 622 623// validateGetAuthorPostsRequest validates the GetAuthorPosts request 624func (s *postService) validateGetAuthorPostsRequest(req *GetAuthorPostsRequest) error { 625 // Validate actor DID is set 626 if req.ActorDID == "" { 627 return NewValidationError("actor", "actor is required") 628 } 629 630 // Validate DID format - AT Protocol supports did:plc and did:web 631 if err := validateDIDFormat(req.ActorDID); err != nil { 632 return NewValidationError("actor", err.Error()) 633 } 634 635 // Validate and set defaults for filter 636 validFilters := map[string]bool{ 637 FilterPostsWithReplies: true, 638 FilterPostsNoReplies: true, 639 FilterPostsWithMedia: true, 640 } 641 if req.Filter == "" { 642 req.Filter = FilterPostsWithReplies // Default 643 } 644 if !validFilters[req.Filter] { 645 return NewValidationError("filter", "filter must be one of: posts_with_replies, posts_no_replies, posts_with_media") 646 } 647 648 // Validate and set defaults for limit 649 if req.Limit <= 0 { 650 req.Limit = 50 // Default 651 } 652 if req.Limit > 100 { 653 req.Limit = 100 // Max 654 } 655 656 return nil 657} 658 659// validateDIDFormat validates that a string is a properly formatted DID 660// Supports did:plc: (24 char base32 identifier) and did:web: (domain-based) 661func validateDIDFormat(did string) error { 662 const maxDIDLength = 2048 663 664 if len(did) > maxDIDLength { 665 return fmt.Errorf("DID exceeds maximum length") 666 } 667 668 switch { 669 case strings.HasPrefix(did, "did:plc:"): 670 // did:plc: format - identifier is 24 lowercase alphanumeric chars 671 identifier := strings.TrimPrefix(did, "did:plc:") 672 if len(identifier) == 0 { 673 return fmt.Errorf("invalid did:plc format: missing identifier") 674 } 675 // Base32 uses lowercase a-z and 2-7 676 for _, c := range identifier { 677 if !((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7')) { 678 return fmt.Errorf("invalid did:plc format: identifier contains invalid characters") 679 } 680 } 681 return nil 682 683 case strings.HasPrefix(did, "did:web:"): 684 // did:web: format - domain-based identifier 685 domain := strings.TrimPrefix(did, "did:web:") 686 if len(domain) == 0 { 687 return fmt.Errorf("invalid did:web format: missing domain") 688 } 689 // Basic domain validation - must contain at least one dot or be localhost 690 if !strings.Contains(domain, ".") && domain != "localhost" { 691 return fmt.Errorf("invalid did:web format: invalid domain") 692 } 693 return nil 694 695 default: 696 return fmt.Errorf("unsupported DID method: must be did:plc or did:web") 697 } 698} 699 700// DeletePost deletes a post from the community's PDS repository 701// SECURITY: Only the post author can delete their own posts 702// Flow: 703// 1. Validate session and URI format 704// 2. Extract community DID and rkey from URI 705// 3. Fetch community from AppView 706// 4. Ensure fresh PDS credentials 707// 5. Fetch post record from community's PDS to get author field 708// 6. SECURITY: Verify author matches session.AccountDID 709// 7. Delete record from community's PDS using community credentials 710func (s *postService) DeletePost(ctx context.Context, session *oauth.ClientSessionData, req DeletePostRequest) error { 711 // 1. Validate session 712 if session == nil { 713 return NewValidationError("session", "OAuth session required") 714 } 715 userDID := session.AccountDID.String() 716 717 // 2. Validate URI format: at://community_did/social.coves.community.post/rkey 718 if err := s.validateDeleteRequest(&req); err != nil { 719 return err 720 } 721 722 // 3. Extract community DID and rkey from URI 723 communityDID, rkey, err := s.parsePostURI(req.URI) 724 if err != nil { 725 return err 726 } 727 728 // 4. Fetch community from AppView 729 community, err := s.communityService.GetByDID(ctx, communityDID) 730 if err != nil { 731 if communities.IsNotFound(err) { 732 return ErrCommunityNotFound 733 } 734 return fmt.Errorf("failed to fetch community: %w", err) 735 } 736 737 // 5. Ensure community has fresh PDS credentials 738 community, err = s.communityService.EnsureFreshToken(ctx, community) 739 if err != nil { 740 return fmt.Errorf("failed to refresh community credentials: %w", err) 741 } 742 743 // 6. Create PDS client for community repository 744 pdsClient, err := pds.NewFromAccessToken(community.PDSURL, community.DID, community.PDSAccessToken) 745 if err != nil { 746 return fmt.Errorf("failed to create PDS client: %w", err) 747 } 748 749 // 7. Fetch post record from PDS to verify author 750 record, err := pdsClient.GetRecord(ctx, "social.coves.community.post", rkey) 751 if err != nil { 752 if errors.Is(err, pds.ErrNotFound) { 753 // Post already deleted or never existed - idempotent success 754 log.Printf("[POST-DELETE] Post not found on PDS (already deleted?): %s", req.URI) 755 return nil 756 } 757 return fmt.Errorf("failed to fetch post from PDS: %w", err) 758 } 759 760 // 8. SECURITY: Verify the requesting user is the post author 761 // The author field in the record must match the authenticated user's DID 762 postAuthor, ok := record.Value["author"].(string) 763 if !ok || postAuthor == "" { 764 return fmt.Errorf("post record missing author field: %s", req.URI) 765 } 766 767 if postAuthor != userDID { 768 log.Printf("[SECURITY] Post delete authorization failed: user=%s, author=%s, uri=%s", 769 userDID, postAuthor, req.URI) 770 return ErrNotAuthorized 771 } 772 773 // 9. Delete record from community's PDS 774 if err := pdsClient.DeleteRecord(ctx, "social.coves.community.post", rkey); err != nil { 775 if errors.Is(err, pds.ErrNotFound) { 776 // Already deleted - idempotent success 777 log.Printf("[POST-DELETE] Post already deleted from PDS: %s", req.URI) 778 return nil 779 } 780 return fmt.Errorf("failed to delete post from PDS: %w", err) 781 } 782 783 // 10. Log success (AppView will update via Jetstream consumer) 784 log.Printf("[POST-DELETE] Successfully deleted post: uri=%s, author=%s, community=%s", 785 req.URI, userDID, communityDID) 786 787 return nil 788} 789 790// validateDeleteRequest validates the delete post request 791func (s *postService) validateDeleteRequest(req *DeletePostRequest) error { 792 if req.URI == "" { 793 return NewValidationError("uri", "post URI is required") 794 } 795 796 // Basic URI format check 797 if !strings.HasPrefix(req.URI, "at://") { 798 return NewValidationError("uri", "invalid AT-URI format: must start with at://") 799 } 800 801 return nil 802} 803 804// parsePostURI extracts community DID and rkey from a post URI 805// Format: at://community_did/social.coves.community.post/rkey 806// Returns community DID, rkey, and error 807func (s *postService) parsePostURI(uri string) (communityDID string, rkey string, err error) { 808 // Remove at:// prefix 809 withoutScheme := strings.TrimPrefix(uri, "at://") 810 parts := strings.Split(withoutScheme, "/") 811 812 // Expected format: [community_did, collection, rkey] 813 if len(parts) != 3 { 814 return "", "", NewValidationError("uri", "invalid post URI format: expected at://did/collection/rkey") 815 } 816 817 communityDID = parts[0] 818 collection := parts[1] 819 rkey = parts[2] 820 821 // Validate collection type 822 if collection != "social.coves.community.post" { 823 return "", "", NewValidationError("uri", fmt.Sprintf("invalid collection in URI: expected social.coves.community.post, got %s", collection)) 824 } 825 826 // Validate DID format 827 if err := validateDIDFormat(communityDID); err != nil { 828 return "", "", NewValidationError("uri", fmt.Sprintf("invalid community DID in URI: %s", err.Error())) 829 } 830 831 // Validate rkey is not empty 832 if rkey == "" { 833 return "", "", NewValidationError("uri", "missing rkey in post URI") 834 } 835 836 // Also verify with utils helper for consistency 837 extractedRkey := utils.ExtractRKeyFromURI(uri) 838 if extractedRkey != rkey { 839 return "", "", NewValidationError("uri", "URI parsing inconsistency") 840 } 841 842 return communityDID, rkey, nil 843}