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}