A community based topic aggregation platform built on atproto
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}