···
"Coves/internal/api/middleware"
"Coves/internal/core/aggregators"
6
+
"Coves/internal/core/blobs"
"Coves/internal/core/communities"
8
+
"Coves/internal/core/unfurl"
···
···
communityService communities.Service
aggregatorService aggregators.Service
24
+
blobService blobs.Service
25
+
unfurlService unfurl.Service
// NewPostService creates a new post service
25
-
// aggregatorService can be nil if aggregator support is not needed (e.g., in tests or minimal setups)
30
+
// aggregatorService, blobService, and unfurlService can be nil if not needed (e.g., in tests or minimal setups)
communityService communities.Service,
aggregatorService aggregators.Service, // Optional: can be nil
35
+
blobService blobs.Service, // Optional: can be nil
36
+
unfurlService unfurl.Service, // Optional: can be nil
communityService: communityService,
aggregatorService: aggregatorService,
43
+
blobService: blobService,
44
+
unfurlService: unfurlService,
···
// 7. If aggregator: record post for rate limiting
// 8. Return URI/CID (AppView indexes asynchronously via Jetstream)
func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {
51
-
// 1. SECURITY: Extract authenticated DID from context (set by JWT middleware)
60
+
// 1. Validate basic input (before DID checks to give clear validation errors)
61
+
if err := s.validateCreateRequest(req); err != nil {
65
+
// 2. SECURITY: Extract authenticated DID from context (set by JWT middleware)
// Defense-in-depth: verify service layer receives correct DID even if handler is bypassed
authenticatedDID := middleware.GetAuthenticatedDID(ctx)
if authenticatedDID == "" {
···
return nil, fmt.Errorf("authenticated DID does not match author DID")
66
-
// 2. Validate basic input
67
-
if err := s.validateCreateRequest(req); err != nil {
80
+
// 3. Determine actor type: Kagi aggregator, other aggregator, or regular user
81
+
kagiAggregatorDID := os.Getenv("KAGI_AGGREGATOR_DID")
82
+
isTrustedKagi := kagiAggregatorDID != "" && req.AuthorDID == kagiAggregatorDID
71
-
// 3. SECURITY: Check if the authenticated DID is a registered aggregator
72
-
// This is server-side verification - we query the database to confirm
73
-
// the DID from the JWT corresponds to a registered aggregator service
74
-
// If aggregatorService is nil (tests or environments without aggregators), treat all posts as user posts
75
-
isAggregator := false
76
-
if s.aggregatorService != nil {
78
-
isAggregator, err = s.aggregatorService.IsAggregator(ctx, req.AuthorDID)
84
+
// Check if this is a non-Kagi aggregator (requires database lookup)
85
+
var isOtherAggregator bool
87
+
if !isTrustedKagi && s.aggregatorService != nil {
88
+
isOtherAggregator, err = s.aggregatorService.IsAggregator(ctx, req.AuthorDID)
80
-
return nil, fmt.Errorf("failed to check if author is aggregator: %w", err)
90
+
log.Printf("[POST-CREATE] Warning: failed to check if DID is aggregator: %v", err)
91
+
// Don't fail the request - treat as regular user if check fails
92
+
isOtherAggregator = false
···
return nil, fmt.Errorf("failed to resolve community identifier: %w", err)
103
-
// 5. Fetch community from AppView (includes all metadata)
115
+
// 5. AUTHORIZATION: For non-Kagi aggregators, validate authorization and rate limits
116
+
// Kagi is exempted from database checks via env var (temporary until XRPC endpoint is ready)
117
+
if isOtherAggregator && s.aggregatorService != nil {
118
+
if err := s.aggregatorService.ValidateAggregatorPost(ctx, req.AuthorDID, communityDID); err != nil {
119
+
log.Printf("[POST-CREATE] Aggregator authorization failed: %s -> %s: %v", req.AuthorDID, communityDID, err)
120
+
return nil, fmt.Errorf("aggregator not authorized: %w", err)
122
+
log.Printf("[POST-CREATE] Aggregator authorized: %s -> %s", req.AuthorDID, communityDID)
125
+
// 6. Fetch community from AppView (includes all metadata)
community, err := s.communityService.GetByDID(ctx, communityDID)
if communities.IsNotFound(err) {
···
return nil, fmt.Errorf("failed to fetch community: %w", err)
112
-
// 6. Apply validation based on actor type (aggregator vs user)
114
-
// AGGREGATOR VALIDATION FLOW
115
-
// Following Bluesky's pattern: feed generators and labelers are authorized services
116
-
log.Printf("[POST-CREATE] Aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID)
118
-
// Check authorization exists and is enabled, and verify rate limits
119
-
if err := s.aggregatorService.ValidateAggregatorPost(ctx, req.AuthorDID, communityDID); err != nil {
120
-
if aggregators.IsUnauthorized(err) {
121
-
return nil, ErrNotAuthorized
123
-
if aggregators.IsRateLimited(err) {
124
-
return nil, ErrRateLimitExceeded
126
-
return nil, fmt.Errorf("aggregator validation failed: %w", err)
134
+
// 7. Apply validation based on actor type (aggregator vs user)
136
+
// TRUSTED AGGREGATOR VALIDATION FLOW
137
+
// Kagi aggregator is authorized via KAGI_AGGREGATOR_DID env var (temporary)
138
+
// TODO: Replace with proper XRPC aggregator authorization endpoint
139
+
log.Printf("[POST-CREATE] Trusted Kagi aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID)
// Aggregators skip membership checks and visibility restrictions
// They are authorized services, not community members
142
+
} else if isOtherAggregator {
143
+
// OTHER AGGREGATOR VALIDATION FLOW
144
+
// Authorization and rate limits already validated above via ValidateAggregatorPost
145
+
log.Printf("[POST-CREATE] Authorized aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID)
// Check community visibility (Alpha: public/unlisted only)
···
140
-
// 7. Ensure community has fresh PDS credentials (token refresh if needed)
155
+
// 8. Ensure community has fresh PDS credentials (token refresh if needed)
community, err = s.communityService.EnsureFreshToken(ctx, community)
return nil, fmt.Errorf("failed to refresh community credentials: %w", err)
146
-
// 8. Build post record for PDS
161
+
// 9. Build post record for PDS
postRecord := PostRecord{
Type: "social.coves.community.post",
···
169
+
Embed: req.Embed, // Start with user-provided embed
OriginalAuthor: req.OriginalAuthor,
FederatedFrom: req.FederatedFrom,
···
CreatedAt: time.Now().UTC().Format(time.RFC3339),
162
-
// 9. Write to community's PDS repository
177
+
// 10. Validate and enhance external embeds
178
+
if postRecord.Embed != nil {
179
+
if embedType, ok := postRecord.Embed["$type"].(string); ok && embedType == "social.coves.embed.external" {
180
+
if external, ok := postRecord.Embed["external"].(map[string]interface{}); ok {
181
+
// SECURITY: Validate thumb field (must be blob, not URL string)
182
+
// This validation happens BEFORE unfurl to catch client errors early
183
+
if existingThumb := external["thumb"]; existingThumb != nil {
184
+
if thumbStr, isString := existingThumb.(string); isString {
185
+
return nil, NewValidationError("thumb",
186
+
fmt.Sprintf("thumb must be a blob reference (with $type, ref, mimeType, size), not URL string: %s", thumbStr))
189
+
// Validate blob structure if provided
190
+
if thumbMap, isMap := existingThumb.(map[string]interface{}); isMap {
191
+
// Check for $type field
192
+
if thumbType, ok := thumbMap["$type"].(string); !ok || thumbType != "blob" {
193
+
return nil, NewValidationError("thumb",
194
+
fmt.Sprintf("thumb must have $type: blob (got: %v)", thumbType))
196
+
// Check for required blob fields
197
+
if _, hasRef := thumbMap["ref"]; !hasRef {
198
+
return nil, NewValidationError("thumb", "thumb blob missing required 'ref' field")
200
+
if _, hasMimeType := thumbMap["mimeType"]; !hasMimeType {
201
+
return nil, NewValidationError("thumb", "thumb blob missing required 'mimeType' field")
203
+
log.Printf("[POST-CREATE] Client provided valid thumbnail blob")
205
+
return nil, NewValidationError("thumb",
206
+
fmt.Sprintf("thumb must be a blob object, got: %T", existingThumb))
210
+
// TRUSTED AGGREGATOR: Allow Kagi aggregator to provide thumbnail URLs directly
211
+
// This bypasses unfurl for more accurate RSS-sourced thumbnails
212
+
if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedKagi {
213
+
log.Printf("[AGGREGATOR-THUMB] Trusted aggregator provided thumbnail: %s", *req.ThumbnailURL)
215
+
if s.blobService != nil {
216
+
blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second)
219
+
blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, *req.ThumbnailURL)
220
+
if blobErr != nil {
221
+
log.Printf("[AGGREGATOR-THUMB] Failed to upload thumbnail: %v", blobErr)
222
+
// No fallback - aggregators only use RSS feed thumbnails
224
+
external["thumb"] = blob
225
+
log.Printf("[AGGREGATOR-THUMB] Successfully uploaded thumbnail from trusted aggregator")
230
+
// Unfurl enhancement (optional, only if URL is supported)
231
+
// Skip unfurl for trusted aggregators - they provide their own metadata
232
+
if !isTrustedKagi {
233
+
if uri, ok := external["uri"].(string); ok && uri != "" {
234
+
// Check if we support unfurling this URL
235
+
if s.unfurlService != nil && s.unfurlService.IsSupported(uri) {
236
+
log.Printf("[POST-CREATE] Unfurling URL: %s", uri)
238
+
// Unfurl with timeout (non-fatal if it fails)
239
+
unfurlCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
242
+
result, err := s.unfurlService.UnfurlURL(unfurlCtx, uri)
244
+
// Log but don't fail - user can still post with manual metadata
245
+
log.Printf("[POST-CREATE] Warning: Failed to unfurl URL %s: %v", uri, err)
247
+
// Enhance embed with fetched metadata (only if client didn't provide)
248
+
// Note: We respect client-provided values, even empty strings
249
+
// If client sends title="", we assume they want no title
250
+
if external["title"] == nil {
251
+
external["title"] = result.Title
253
+
if external["description"] == nil {
254
+
external["description"] = result.Description
256
+
// Always set metadata fields (provider, domain, type)
257
+
external["embedType"] = result.Type
258
+
external["provider"] = result.Provider
259
+
external["domain"] = result.Domain
261
+
// Upload thumbnail from unfurl if client didn't provide one
262
+
// (Thumb validation already happened above)
263
+
if external["thumb"] == nil {
264
+
if result.ThumbnailURL != "" && s.blobService != nil {
265
+
blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second)
268
+
blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, result.ThumbnailURL)
269
+
if blobErr != nil {
270
+
log.Printf("[POST-CREATE] Warning: Failed to upload thumbnail for %s: %v", uri, blobErr)
272
+
external["thumb"] = blob
273
+
log.Printf("[POST-CREATE] Uploaded thumbnail blob for %s", uri)
278
+
log.Printf("[POST-CREATE] Successfully enhanced embed with unfurl data (provider: %s, type: %s)",
279
+
result.Provider, result.Type)
288
+
// 11. Write to community's PDS repository
uri, cid, err := s.createPostOnPDS(ctx, community, postRecord)
return nil, fmt.Errorf("failed to write post to PDS: %w", err)
168
-
// 10. If aggregator, record post for rate limiting and statistics
169
-
if isAggregator && s.aggregatorService != nil {
170
-
if err := s.aggregatorService.RecordAggregatorPost(ctx, req.AuthorDID, communityDID, uri, cid); err != nil {
171
-
// Log error but don't fail the request (post was already created on PDS)
172
-
log.Printf("[POST-CREATE] Warning: failed to record aggregator post for rate limiting: %v", err)
294
+
// 12. Record aggregator post for rate limiting (non-Kagi aggregators only)
295
+
// Kagi is exempted from rate limiting via env var (temporary)
296
+
if isOtherAggregator && s.aggregatorService != nil {
297
+
if recordErr := s.aggregatorService.RecordAggregatorPost(ctx, req.AuthorDID, communityDID, uri, cid); recordErr != nil {
298
+
// Log but don't fail - post was already created successfully
299
+
log.Printf("[POST-CREATE] Warning: failed to record aggregator post for rate limiting: %v", recordErr)
176
-
// 11. Return response (AppView will index via Jetstream consumer)
177
-
log.Printf("[POST-CREATE] Author: %s (aggregator=%v), Community: %s, URI: %s",
178
-
req.AuthorDID, isAggregator, communityDID, uri)
303
+
// 13. Return response (AppView will index via Jetstream consumer)
304
+
log.Printf("[POST-CREATE] Author: %s (trustedKagi=%v, otherAggregator=%v), Community: %s, URI: %s",
305
+
req.AuthorDID, isTrustedKagi, isOtherAggregator, communityDID, uri)
return &CreatePostResponse{