A community based topic aggregation platform built on atproto
1package posts 2 3import ( 4 "Coves/internal/core/communities" 5 "bytes" 6 "context" 7 "encoding/json" 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "time" 13) 14 15type postService struct { 16 repo Repository 17 communityService communities.Service 18 pdsURL string 19} 20 21// NewPostService creates a new post service 22func NewPostService( 23 repo Repository, 24 communityService communities.Service, 25 pdsURL string, 26) Service { 27 return &postService{ 28 repo: repo, 29 communityService: communityService, 30 pdsURL: pdsURL, 31 } 32} 33 34// CreatePost creates a new post in a community 35// Flow: 36// 1. Validate input 37// 2. Resolve community at-identifier (handle or DID) to DID 38// 3. Fetch community from AppView 39// 4. Ensure community has fresh PDS credentials 40// 5. Build post record 41// 6. Write to community's PDS repository 42// 7. Return URI/CID (AppView indexes asynchronously via Jetstream) 43func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) { 44 // 1. Validate basic input 45 if err := s.validateCreateRequest(req); err != nil { 46 return nil, err 47 } 48 49 // 2. Resolve community at-identifier (handle or DID) to DID 50 // This accepts both formats per atProto best practices: 51 // - Handles: !gardening.communities.coves.social 52 // - DIDs: did:plc:abc123 or did:web:coves.social 53 communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community) 54 if err != nil { 55 // Handle specific error types appropriately 56 if communities.IsNotFound(err) { 57 return nil, ErrCommunityNotFound 58 } 59 if communities.IsValidationError(err) { 60 // Pass through validation errors (invalid format, etc.) 61 return nil, NewValidationError("community", err.Error()) 62 } 63 // Infrastructure failures (DB errors, network issues) should be internal errors 64 // Don't leak internal details to client (e.g., "pq: connection refused") 65 return nil, fmt.Errorf("failed to resolve community identifier: %w", err) 66 } 67 68 // 3. Fetch community from AppView (includes all metadata) 69 community, err := s.communityService.GetByDID(ctx, communityDID) 70 if err != nil { 71 if communities.IsNotFound(err) { 72 return nil, ErrCommunityNotFound 73 } 74 return nil, fmt.Errorf("failed to fetch community: %w", err) 75 } 76 77 // 4. Check community visibility (Alpha: public/unlisted only) 78 // Beta will add membership checks for private communities 79 if community.Visibility == "private" { 80 return nil, ErrNotAuthorized 81 } 82 83 // 5. Ensure community has fresh PDS credentials (token refresh if needed) 84 community, err = s.communityService.EnsureFreshToken(ctx, community) 85 if err != nil { 86 return nil, fmt.Errorf("failed to refresh community credentials: %w", err) 87 } 88 89 // 6. Build post record for PDS 90 postRecord := PostRecord{ 91 Type: "social.coves.post.record", 92 Community: communityDID, 93 Author: req.AuthorDID, 94 Title: req.Title, 95 Content: req.Content, 96 Facets: req.Facets, 97 Embed: req.Embed, 98 ContentLabels: req.ContentLabels, 99 OriginalAuthor: req.OriginalAuthor, 100 FederatedFrom: req.FederatedFrom, 101 Location: req.Location, 102 CreatedAt: time.Now().UTC().Format(time.RFC3339), 103 } 104 105 // 7. Write to community's PDS repository 106 uri, cid, err := s.createPostOnPDS(ctx, community, postRecord) 107 if err != nil { 108 return nil, fmt.Errorf("failed to write post to PDS: %w", err) 109 } 110 111 // 8. Return response (AppView will index via Jetstream consumer) 112 log.Printf("[POST-CREATE] Author: %s, Community: %s, URI: %s", req.AuthorDID, communityDID, uri) 113 114 return &CreatePostResponse{ 115 URI: uri, 116 CID: cid, 117 }, nil 118} 119 120// validateCreateRequest validates basic input requirements 121func (s *postService) validateCreateRequest(req CreatePostRequest) error { 122 // Global content limits (from lexicon) 123 const ( 124 maxContentLength = 50000 // 50k characters 125 maxTitleLength = 3000 // 3k bytes 126 maxTitleGraphemes = 300 // 300 graphemes (simplified check) 127 ) 128 129 // Validate community required 130 if req.Community == "" { 131 return NewValidationError("community", "community is required") 132 } 133 134 // Validate author DID set by handler 135 if req.AuthorDID == "" { 136 return NewValidationError("authorDid", "authorDid must be set from authenticated user") 137 } 138 139 // Validate content length 140 if req.Content != nil && len(*req.Content) > maxContentLength { 141 return NewValidationError("content", 142 fmt.Sprintf("content too long (max %d characters)", maxContentLength)) 143 } 144 145 // Validate title length 146 if req.Title != nil { 147 if len(*req.Title) > maxTitleLength { 148 return NewValidationError("title", 149 fmt.Sprintf("title too long (max %d bytes)", maxTitleLength)) 150 } 151 // Simplified grapheme check (actual implementation would need unicode library) 152 // For Alpha, byte length check is sufficient 153 } 154 155 // Validate content labels are from known values 156 validLabels := map[string]bool{ 157 "nsfw": true, 158 "spoiler": true, 159 "violence": true, 160 } 161 for _, label := range req.ContentLabels { 162 if !validLabels[label] { 163 return NewValidationError("contentLabels", 164 fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label)) 165 } 166 } 167 168 return nil 169} 170 171// createPostOnPDS writes a post record to the community's PDS repository 172// Uses com.atproto.repo.createRecord endpoint 173func (s *postService) createPostOnPDS( 174 ctx context.Context, 175 community *communities.Community, 176 record PostRecord, 177) (uri, cid string, err error) { 178 // Use community's PDS URL (not service default) for federated communities 179 // Each community can be hosted on a different PDS instance 180 pdsURL := community.PDSURL 181 if pdsURL == "" { 182 // Fallback to service default if community doesn't have a PDS URL 183 // (shouldn't happen in practice, but safe default) 184 pdsURL = s.pdsURL 185 } 186 187 // Build PDS endpoint URL 188 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL) 189 190 // Build request payload 191 // IMPORTANT: repo is set to community DID, not author DID 192 // This writes the post to the community's repository 193 payload := map[string]interface{}{ 194 "repo": community.DID, // Community's repository 195 "collection": "social.coves.post.record", // Collection type 196 "record": record, // The post record 197 // "rkey" omitted - PDS will auto-generate TID 198 } 199 200 // Marshal payload 201 jsonData, err := json.Marshal(payload) 202 if err != nil { 203 return "", "", fmt.Errorf("failed to marshal post payload: %w", err) 204 } 205 206 // Create HTTP request 207 req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 208 if err != nil { 209 return "", "", fmt.Errorf("failed to create PDS request: %w", err) 210 } 211 212 // Set headers (auth + content type) 213 req.Header.Set("Content-Type", "application/json") 214 req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken) 215 216 // Extended timeout for write operations (30 seconds) 217 client := &http.Client{ 218 Timeout: 30 * time.Second, 219 } 220 221 // Execute request 222 resp, err := client.Do(req) 223 if err != nil { 224 return "", "", fmt.Errorf("PDS request failed: %w", err) 225 } 226 defer func() { 227 if closeErr := resp.Body.Close(); closeErr != nil { 228 log.Printf("Warning: failed to close response body: %v", closeErr) 229 } 230 }() 231 232 // Read response body 233 body, err := io.ReadAll(resp.Body) 234 if err != nil { 235 return "", "", fmt.Errorf("failed to read PDS response: %w", err) 236 } 237 238 // Check for errors 239 if resp.StatusCode != http.StatusOK { 240 // Sanitize error body for logging (prevent sensitive data leakage) 241 bodyPreview := string(body) 242 if len(bodyPreview) > 200 { 243 bodyPreview = bodyPreview[:200] + "... (truncated)" 244 } 245 log.Printf("[POST-CREATE-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview) 246 247 // Return truncated error (defense in depth - handler will mask this further) 248 return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview) 249 } 250 251 // Parse response 252 var result struct { 253 URI string `json:"uri"` 254 CID string `json:"cid"` 255 } 256 if err := json.Unmarshal(body, &result); err != nil { 257 return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 258 } 259 260 return result.URI, result.CID, nil 261}