diff --git a/internal/atproto/jetstream/post_consumer.go b/internal/atproto/jetstream/post_consumer.go index ea94c33..899fa38 100644 --- a/internal/atproto/jetstream/post_consumer.go +++ b/internal/atproto/jetstream/post_consumer.go @@ -13,9 +13,9 @@ import ( ) // PostEventConsumer consumes post-related events from Jetstream -// Currently handles only CREATE operations for social.coves.post.record +// Currently handles only CREATE operations for social.coves.community.post // UPDATE and DELETE handlers will be added when those features are implemented -type PostEventConsumer struct { +type PostEventConsumer struct{ postRepo posts.Repository communityRepo communities.Repository userService users.UserService @@ -46,7 +46,7 @@ func (c *PostEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEve // Only handle post record creation for now // UPDATE and DELETE will be added when we implement those features - if commit.Collection == "social.coves.post.record" && commit.Operation == "create" { + if commit.Collection == "social.coves.community.post" && commit.Operation == "create" { return c.createPost(ctx, event.Did, commit) } @@ -73,8 +73,8 @@ func (c *PostEventConsumer) createPost(ctx context.Context, repoDID string, comm } // Build AT-URI for this post - // Format: at://community_did/social.coves.post.record/rkey - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey) + // Format: at://community_did/social.coves.community.post/rkey + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey) // Parse timestamp from record createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt) @@ -119,8 +119,8 @@ func (c *PostEventConsumer) createPost(ctx context.Context, repoDID string, comm } } - if len(postRecord.ContentLabels) > 0 { - labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels) + if postRecord.Labels != nil { + labelsJSON, marshalErr := json.Marshal(postRecord.Labels) if marshalErr == nil { labelsStr := string(labelsJSON) post.ContentLabels = &labelsStr @@ -151,7 +151,7 @@ func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID strin // This prevents users from creating posts that appear to be from communities they don't control // // Example attack prevented: - // - User creates post in their own repo (at://user_did/social.coves.post.record/xyz) + // - User creates post in their own repo (at://user_did/social.coves.community.post/xyz) // - Claims it's for community X (community field = community_did) // - Without this check, fake post would be indexed // @@ -199,8 +199,8 @@ func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID strin } // PostRecordFromJetstream represents a post record as received from Jetstream -// Matches the structure written to PDS via social.coves.post.record -type PostRecordFromJetstream struct { +// Matches the structure written to PDS via social.coves.community.post +type PostRecordFromJetstream struct{ OriginalAuthor interface{} `json:"originalAuthor,omitempty"` FederatedFrom interface{} `json:"federatedFrom,omitempty"` Location interface{} `json:"location,omitempty"` @@ -212,7 +212,7 @@ type PostRecordFromJetstream struct { Author string `json:"author"` CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - ContentLabels []string `json:"contentLabels,omitempty"` + Labels *posts.SelfLabels `json:"labels,omitempty"` } // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream diff --git a/internal/core/posts/post.go b/internal/core/posts/post.go index 860fbbb..e0f74d2 100644 --- a/internal/core/posts/post.go +++ b/internal/core/posts/post.go @@ -4,6 +4,19 @@ import ( "time" ) +// SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels +// This is the structured format used in atProto for content warnings +type SelfLabels struct { + Values []SelfLabel `json:"values"` +} + +// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel +// Neg is optional and negates the label when true +type SelfLabel struct { + Val string `json:"val"` // Required: label value (max 128 chars) + Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true +} + // Post represents a post in the AppView database // Posts are indexed from the firehose after being written to community repositories type Post struct { @@ -12,7 +25,7 @@ type Post struct { EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"` Embed *string `json:"embed,omitempty" db:"embed"` DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` - ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"` + ContentLabels *string `json:"labels,omitempty" db:"content_labels"` Title *string `json:"title,omitempty" db:"title"` Content *string `json:"content,omitempty" db:"content"` ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` @@ -29,7 +42,7 @@ type Post struct { } // CreatePostRequest represents input for creating a new post -// Matches social.coves.post.create lexicon input schema +// Matches social.coves.community.post.create lexicon input schema type CreatePostRequest struct { OriginalAuthor interface{} `json:"originalAuthor,omitempty"` FederatedFrom interface{} `json:"federatedFrom,omitempty"` @@ -40,12 +53,12 @@ type CreatePostRequest struct { Community string `json:"community"` AuthorDID string `json:"authorDid"` Facets []interface{} `json:"facets,omitempty"` - ContentLabels []string `json:"contentLabels,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` } // CreatePostResponse represents the response from creating a post -// Matches social.coves.post.create lexicon output schema -type CreatePostResponse struct { +// Matches social.coves.community.post.create lexicon output schema +type CreatePostResponse struct{ URI string `json:"uri"` // AT-URI of created post CID string `json:"cid"` // CID of created post } @@ -64,11 +77,11 @@ type PostRecord struct { Author string `json:"author"` CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - ContentLabels []string `json:"contentLabels,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` } // PostView represents the full view of a post with all metadata -// Matches social.coves.post.get#postView lexicon +// Matches social.coves.community.post.get#postView lexicon // Used in feeds and get endpoints type PostView struct { IndexedAt time.Time `json:"indexedAt"` diff --git a/internal/core/posts/service.go b/internal/core/posts/service.go index 7dfed6d..8e8b719 100644 --- a/internal/core/posts/service.go +++ b/internal/core/posts/service.go @@ -145,14 +145,14 @@ func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*C // 8. Build post record for PDS postRecord := PostRecord{ - Type: "social.coves.post.record", + Type: "social.coves.community.post", Community: communityDID, Author: req.AuthorDID, Title: req.Title, Content: req.Content, Facets: req.Facets, Embed: req.Embed, - ContentLabels: req.ContentLabels, + Labels: req.Labels, OriginalAuthor: req.OriginalAuthor, FederatedFrom: req.FederatedFrom, Location: req.Location, @@ -187,9 +187,9 @@ func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*C func (s *postService) validateCreateRequest(req CreatePostRequest) error { // Global content limits (from lexicon) const ( - maxContentLength = 50000 // 50k characters - maxTitleLength = 3000 // 3k bytes - maxTitleGraphemes = 300 // 300 graphemes (simplified check) + maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon + maxTitleLength = 3000 // 3k bytes + maxTitleGraphemes = 300 // 300 graphemes (simplified check) ) // Validate community required @@ -219,15 +219,17 @@ func (s *postService) validateCreateRequest(req CreatePostRequest) error { } // Validate content labels are from known values - validLabels := map[string]bool{ - "nsfw": true, - "spoiler": true, - "violence": true, - } - for _, label := range req.ContentLabels { - if !validLabels[label] { - return NewValidationError("contentLabels", - fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label)) + if req.Labels != nil { + validLabels := map[string]bool{ + "nsfw": true, + "spoiler": true, + "violence": true, + } + for _, label := range req.Labels.Values { + if !validLabels[label.Val] { + return NewValidationError("labels", + fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val)) + } } } @@ -257,9 +259,9 @@ func (s *postService) createPostOnPDS( // IMPORTANT: repo is set to community DID, not author DID // This writes the post to the community's repository payload := map[string]interface{}{ - "repo": community.DID, // Community's repository - "collection": "social.coves.post.record", // Collection type - "record": record, // The post record + "repo": community.DID, // Community's repository + "collection": "social.coves.community.post", // Collection type + "record": record, // The post record // "rkey" omitted - PDS will auto-generate TID }