···
4
+
"Coves/internal/core/communities"
15
+
type postService struct {
17
+
communityService communities.Service
21
+
// NewPostService creates a new post service
22
+
func NewPostService(
24
+
communityService communities.Service,
27
+
return &postService{
29
+
communityService: communityService,
34
+
// CreatePost creates a new post in a community
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)
43
+
func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {
44
+
// 1. Validate basic input
45
+
if err := s.validateCreateRequest(req); err != nil {
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)
55
+
// Handle specific error types appropriately
56
+
if communities.IsNotFound(err) {
57
+
return nil, ErrCommunityNotFound
59
+
if communities.IsValidationError(err) {
60
+
// Pass through validation errors (invalid format, etc.)
61
+
return nil, NewValidationError("community", err.Error())
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)
68
+
// 3. Fetch community from AppView (includes all metadata)
69
+
community, err := s.communityService.GetByDID(ctx, communityDID)
71
+
if communities.IsNotFound(err) {
72
+
return nil, ErrCommunityNotFound
74
+
return nil, fmt.Errorf("failed to fetch community: %w", err)
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
83
+
// 5. Ensure community has fresh PDS credentials (token refresh if needed)
84
+
community, err = s.communityService.EnsureFreshToken(ctx, community)
86
+
return nil, fmt.Errorf("failed to refresh community credentials: %w", err)
89
+
// 6. Build post record for PDS
90
+
postRecord := PostRecord{
91
+
Type: "social.coves.post.record",
92
+
Community: communityDID,
93
+
Author: req.AuthorDID,
95
+
Content: req.Content,
98
+
ContentLabels: req.ContentLabels,
99
+
OriginalAuthor: req.OriginalAuthor,
100
+
FederatedFrom: req.FederatedFrom,
101
+
Location: req.Location,
102
+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
105
+
// 7. Write to community's PDS repository
106
+
uri, cid, err := s.createPostOnPDS(ctx, community, postRecord)
108
+
return nil, fmt.Errorf("failed to write post to PDS: %w", err)
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)
114
+
return &CreatePostResponse{
120
+
// validateCreateRequest validates basic input requirements
121
+
func (s *postService) validateCreateRequest(req CreatePostRequest) error {
122
+
// Global content limits (from lexicon)
124
+
maxContentLength = 50000 // 50k characters
125
+
maxTitleLength = 3000 // 3k bytes
126
+
maxTitleGraphemes = 300 // 300 graphemes (simplified check)
129
+
// Validate community required
130
+
if req.Community == "" {
131
+
return NewValidationError("community", "community is required")
134
+
// Validate author DID set by handler
135
+
if req.AuthorDID == "" {
136
+
return NewValidationError("authorDid", "authorDid must be set from authenticated user")
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))
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))
151
+
// Simplified grapheme check (actual implementation would need unicode library)
152
+
// For Alpha, byte length check is sufficient
155
+
// Validate content labels are from known values
156
+
validLabels := map[string]bool{
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))
171
+
// createPostOnPDS writes a post record to the community's PDS repository
172
+
// Uses com.atproto.repo.createRecord endpoint
173
+
func (s *postService) createPostOnPDS(
174
+
ctx context.Context,
175
+
community *communities.Community,
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
182
+
// Fallback to service default if community doesn't have a PDS URL
183
+
// (shouldn't happen in practice, but safe default)
187
+
// Build PDS endpoint URL
188
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL)
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
201
+
jsonData, err := json.Marshal(payload)
203
+
return "", "", fmt.Errorf("failed to marshal post payload: %w", err)
206
+
// Create HTTP request
207
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
209
+
return "", "", fmt.Errorf("failed to create PDS request: %w", err)
212
+
// Set headers (auth + content type)
213
+
req.Header.Set("Content-Type", "application/json")
214
+
req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken)
216
+
// Extended timeout for write operations (30 seconds)
217
+
client := &http.Client{
218
+
Timeout: 30 * time.Second,
222
+
resp, err := client.Do(req)
224
+
return "", "", fmt.Errorf("PDS request failed: %w", err)
227
+
if closeErr := resp.Body.Close(); closeErr != nil {
228
+
log.Printf("Warning: failed to close response body: %v", closeErr)
232
+
// Read response body
233
+
body, err := io.ReadAll(resp.Body)
235
+
return "", "", fmt.Errorf("failed to read PDS response: %w", err)
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)"
245
+
log.Printf("[POST-CREATE-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview)
247
+
// Return truncated error (defense in depth - handler will mask this further)
248
+
return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview)
252
+
var result struct {
253
+
URI string `json:"uri"`
254
+
CID string `json:"cid"`
256
+
if err := json.Unmarshal(body, &result); err != nil {
257
+
return "", "", fmt.Errorf("failed to parse PDS response: %w", err)
260
+
return result.URI, result.CID, nil