···
"Coves/internal/api/middleware"
"Coves/internal/core/aggregators"
+
"Coves/internal/core/blobs"
"Coves/internal/core/communities"
+
"Coves/internal/core/unfurl"
···
···
communityService communities.Service
aggregatorService aggregators.Service
+
blobService blobs.Service
+
unfurlService unfurl.Service
// NewPostService creates a new post service
+
// 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
+
blobService blobs.Service, // Optional: can be nil
+
unfurlService unfurl.Service, // Optional: can be nil
communityService: communityService,
aggregatorService: aggregatorService,
+
blobService: blobService,
+
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) {
+
// 1. Validate basic input (before DID checks to give clear validation errors)
+
if err := s.validateCreateRequest(req); err != nil {
+
// 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")
+
// 3. Determine actor type: Kagi aggregator, other aggregator, or regular user
+
kagiAggregatorDID := os.Getenv("KAGI_AGGREGATOR_DID")
+
isTrustedKagi := kagiAggregatorDID != "" && req.AuthorDID == kagiAggregatorDID
+
// Check if this is a non-Kagi aggregator (requires database lookup)
+
var isOtherAggregator bool
+
if !isTrustedKagi && s.aggregatorService != nil {
+
isOtherAggregator, err = s.aggregatorService.IsAggregator(ctx, req.AuthorDID)
+
log.Printf("[POST-CREATE] Warning: failed to check if DID is aggregator: %v", err)
+
// Don't fail the request - treat as regular user if check fails
+
isOtherAggregator = false
···
return nil, fmt.Errorf("failed to resolve community identifier: %w", err)
+
// 5. AUTHORIZATION: For non-Kagi aggregators, validate authorization and rate limits
+
// Kagi is exempted from database checks via env var (temporary until XRPC endpoint is ready)
+
if isOtherAggregator && s.aggregatorService != nil {
+
if err := s.aggregatorService.ValidateAggregatorPost(ctx, req.AuthorDID, communityDID); err != nil {
+
log.Printf("[POST-CREATE] Aggregator authorization failed: %s -> %s: %v", req.AuthorDID, communityDID, err)
+
return nil, fmt.Errorf("aggregator not authorized: %w", err)
+
log.Printf("[POST-CREATE] Aggregator authorized: %s -> %s", req.AuthorDID, communityDID)
+
// 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)
+
// 7. Apply validation based on actor type (aggregator vs user)
+
// TRUSTED AGGREGATOR VALIDATION FLOW
+
// Kagi aggregator is authorized via KAGI_AGGREGATOR_DID env var (temporary)
+
// TODO: Replace with proper XRPC aggregator authorization endpoint
+
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
+
} else if isOtherAggregator {
+
// OTHER AGGREGATOR VALIDATION FLOW
+
// Authorization and rate limits already validated above via ValidateAggregatorPost
+
log.Printf("[POST-CREATE] Authorized aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID)
// Check community visibility (Alpha: public/unlisted only)
···
+
// 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)
+
// 9. Build post record for PDS
postRecord := PostRecord{
Type: "social.coves.community.post",
···
+
Embed: req.Embed, // Start with user-provided embed
OriginalAuthor: req.OriginalAuthor,
FederatedFrom: req.FederatedFrom,
···
CreatedAt: time.Now().UTC().Format(time.RFC3339),
+
// 10. Validate and enhance external embeds
+
if postRecord.Embed != nil {
+
if embedType, ok := postRecord.Embed["$type"].(string); ok && embedType == "social.coves.embed.external" {
+
if external, ok := postRecord.Embed["external"].(map[string]interface{}); ok {
+
// SECURITY: Validate thumb field (must be blob, not URL string)
+
// This validation happens BEFORE unfurl to catch client errors early
+
if existingThumb := external["thumb"]; existingThumb != nil {
+
if thumbStr, isString := existingThumb.(string); isString {
+
return nil, NewValidationError("thumb",
+
fmt.Sprintf("thumb must be a blob reference (with $type, ref, mimeType, size), not URL string: %s", thumbStr))
+
// Validate blob structure if provided
+
if thumbMap, isMap := existingThumb.(map[string]interface{}); isMap {
+
// Check for $type field
+
if thumbType, ok := thumbMap["$type"].(string); !ok || thumbType != "blob" {
+
return nil, NewValidationError("thumb",
+
fmt.Sprintf("thumb must have $type: blob (got: %v)", thumbType))
+
// Check for required blob fields
+
if _, hasRef := thumbMap["ref"]; !hasRef {
+
return nil, NewValidationError("thumb", "thumb blob missing required 'ref' field")
+
if _, hasMimeType := thumbMap["mimeType"]; !hasMimeType {
+
return nil, NewValidationError("thumb", "thumb blob missing required 'mimeType' field")
+
log.Printf("[POST-CREATE] Client provided valid thumbnail blob")
+
return nil, NewValidationError("thumb",
+
fmt.Sprintf("thumb must be a blob object, got: %T", existingThumb))
+
// TRUSTED AGGREGATOR: Allow Kagi aggregator to provide thumbnail URLs directly
+
// This bypasses unfurl for more accurate RSS-sourced thumbnails
+
if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedKagi {
+
log.Printf("[AGGREGATOR-THUMB] Trusted aggregator provided thumbnail: %s", *req.ThumbnailURL)
+
if s.blobService != nil {
+
blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second)
+
blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, *req.ThumbnailURL)
+
log.Printf("[AGGREGATOR-THUMB] Failed to upload thumbnail: %v", blobErr)
+
// No fallback - aggregators only use RSS feed thumbnails
+
external["thumb"] = blob
+
log.Printf("[AGGREGATOR-THUMB] Successfully uploaded thumbnail from trusted aggregator")
+
// Unfurl enhancement (optional, only if URL is supported)
+
// Skip unfurl for trusted aggregators - they provide their own metadata
+
if uri, ok := external["uri"].(string); ok && uri != "" {
+
// Check if we support unfurling this URL
+
if s.unfurlService != nil && s.unfurlService.IsSupported(uri) {
+
log.Printf("[POST-CREATE] Unfurling URL: %s", uri)
+
// Unfurl with timeout (non-fatal if it fails)
+
unfurlCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+
result, err := s.unfurlService.UnfurlURL(unfurlCtx, uri)
+
// Log but don't fail - user can still post with manual metadata
+
log.Printf("[POST-CREATE] Warning: Failed to unfurl URL %s: %v", uri, err)
+
// Enhance embed with fetched metadata (only if client didn't provide)
+
// Note: We respect client-provided values, even empty strings
+
// If client sends title="", we assume they want no title
+
if external["title"] == nil {
+
external["title"] = result.Title
+
if external["description"] == nil {
+
external["description"] = result.Description
+
// Always set metadata fields (provider, domain, type)
+
external["embedType"] = result.Type
+
external["provider"] = result.Provider
+
external["domain"] = result.Domain
+
// Upload thumbnail from unfurl if client didn't provide one
+
// (Thumb validation already happened above)
+
if external["thumb"] == nil {
+
if result.ThumbnailURL != "" && s.blobService != nil {
+
blobCtx, blobCancel := context.WithTimeout(ctx, 15*time.Second)
+
blob, blobErr := s.blobService.UploadBlobFromURL(blobCtx, community, result.ThumbnailURL)
+
log.Printf("[POST-CREATE] Warning: Failed to upload thumbnail for %s: %v", uri, blobErr)
+
external["thumb"] = blob
+
log.Printf("[POST-CREATE] Uploaded thumbnail blob for %s", uri)
+
log.Printf("[POST-CREATE] Successfully enhanced embed with unfurl data (provider: %s, type: %s)",
+
result.Provider, result.Type)
+
// 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)
+
// 12. Record aggregator post for rate limiting (non-Kagi aggregators only)
+
// Kagi is exempted from rate limiting via env var (temporary)
+
if isOtherAggregator && s.aggregatorService != nil {
+
if recordErr := s.aggregatorService.RecordAggregatorPost(ctx, req.AuthorDID, communityDID, uri, cid); recordErr != nil {
+
// Log but don't fail - post was already created successfully
+
log.Printf("[POST-CREATE] Warning: failed to record aggregator post for rate limiting: %v", recordErr)
+
// 13. Return response (AppView will index via Jetstream consumer)
+
log.Printf("[POST-CREATE] Author: %s (trustedKagi=%v, otherAggregator=%v), Community: %s, URI: %s",
+
req.AuthorDID, isTrustedKagi, isOtherAggregator, communityDID, uri)
return &CreatePostResponse{