A community based topic aggregation platform built on atproto
1package postgres 2 3import ( 4 "Coves/internal/core/posts" 5 "crypto/hmac" 6 "crypto/sha256" 7 "database/sql" 8 "encoding/base64" 9 "encoding/hex" 10 "encoding/json" 11 "fmt" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/lib/pq" 17) 18 19// feedRepoBase contains shared logic for timeline and discover feed repositories 20// This eliminates ~85% code duplication and ensures bug fixes apply to both feeds 21// 22// DATABASE INDEXES REQUIRED: 23// The feed queries rely on these indexes (created in migration 011_create_posts_table.sql): 24// 25// 1. idx_posts_community_created ON posts(community_did, created_at DESC) WHERE deleted_at IS NULL 26// - Used by: Both timeline and discover for "new" sort 27// - Covers: Community filtering + chronological ordering + soft delete filter 28// 29// 2. idx_posts_community_score ON posts(community_did, score DESC, created_at DESC) WHERE deleted_at IS NULL 30// - Used by: Both timeline and discover for "top" sort 31// - Covers: Community filtering + score ordering + tie-breaking + soft delete filter 32// 33// 3. idx_subscriptions_user_community ON community_subscriptions(user_did, community_did) 34// - Used by: Timeline feed (JOIN with subscriptions) 35// - Covers: User subscription lookup 36// 37// 4. Hot sort uses computed expression: (score / POWER(age_hours + 2, 1.5)) 38// - Cannot be indexed directly (computed at query time) 39// - Uses idx_posts_community_created for base ordering 40// - Performance: ~10-20ms for timeline, ~8-15ms for discover (acceptable for alpha) 41// 42// PERFORMANCE NOTES: 43// - All queries use single execution (no N+1) 44// - JOINs are minimal (3 for timeline, 2 for discover) 45// - Partial indexes (WHERE deleted_at IS NULL) eliminate soft-deleted posts efficiently 46// - Cursor pagination is stable (no offset drift) 47// - Limit+1 pattern checks for next page without extra query 48type feedRepoBase struct { 49 db *sql.DB 50 hotRankExpression string 51 sortClauses map[string]string 52 cursorSecret string // HMAC secret for cursor integrity protection 53} 54 55// newFeedRepoBase creates a new base repository with shared feed logic 56func newFeedRepoBase(db *sql.DB, hotRankExpr string, sortClauses map[string]string, cursorSecret string) *feedRepoBase { 57 return &feedRepoBase{ 58 db: db, 59 hotRankExpression: hotRankExpr, 60 sortClauses: sortClauses, 61 cursorSecret: cursorSecret, 62 } 63} 64 65// buildSortClause returns the ORDER BY SQL and optional time filter 66// Uses whitelist map to prevent SQL injection via dynamic ORDER BY 67func (r *feedRepoBase) buildSortClause(sort, timeframe string) (string, string) { 68 // Use whitelist map for ORDER BY clause (defense-in-depth against SQL injection) 69 orderBy := r.sortClauses[sort] 70 if orderBy == "" { 71 orderBy = r.sortClauses["hot"] // safe default 72 } 73 74 // Add time filter for "top" sort 75 var timeFilter string 76 if sort == "top" { 77 timeFilter = r.buildTimeFilter(timeframe) 78 } 79 80 return orderBy, timeFilter 81} 82 83// buildTimeFilter returns SQL filter for timeframe 84func (r *feedRepoBase) buildTimeFilter(timeframe string) string { 85 if timeframe == "" || timeframe == "all" { 86 return "" 87 } 88 89 var interval string 90 switch timeframe { 91 case "hour": 92 interval = "1 hour" 93 case "day": 94 interval = "1 day" 95 case "week": 96 interval = "1 week" 97 case "month": 98 interval = "1 month" 99 case "year": 100 interval = "1 year" 101 default: 102 return "" 103 } 104 105 return fmt.Sprintf("AND p.created_at > NOW() - INTERVAL '%s'", interval) 106} 107 108// parseCursor decodes and validates pagination cursor 109// paramOffset is the starting parameter number for cursor values ($2 for discover, $3 for timeline) 110func (r *feedRepoBase) parseCursor(cursor *string, sort string, paramOffset int) (string, []interface{}, error) { 111 if cursor == nil || *cursor == "" { 112 return "", nil, nil 113 } 114 115 // Decode base64 cursor 116 decoded, err := base64.StdEncoding.DecodeString(*cursor) 117 if err != nil { 118 return "", nil, fmt.Errorf("invalid cursor encoding") 119 } 120 121 // Parse cursor: payload::signature 122 parts := strings.Split(string(decoded), "::") 123 if len(parts) < 2 { 124 return "", nil, fmt.Errorf("invalid cursor format") 125 } 126 127 // Verify HMAC signature 128 signatureHex := parts[len(parts)-1] 129 payload := strings.Join(parts[:len(parts)-1], "::") 130 131 expectedMAC := hmac.New(sha256.New, []byte(r.cursorSecret)) 132 expectedMAC.Write([]byte(payload)) 133 expectedSignature := hex.EncodeToString(expectedMAC.Sum(nil)) 134 135 if !hmac.Equal([]byte(signatureHex), []byte(expectedSignature)) { 136 return "", nil, fmt.Errorf("invalid cursor signature") 137 } 138 139 // Parse payload based on sort type 140 payloadParts := strings.Split(payload, "::") 141 142 switch sort { 143 case "new": 144 // Cursor format: timestamp::uri 145 if len(payloadParts) != 2 { 146 return "", nil, fmt.Errorf("invalid cursor format") 147 } 148 149 createdAt := payloadParts[0] 150 uri := payloadParts[1] 151 152 // Validate timestamp format 153 if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { 154 return "", nil, fmt.Errorf("invalid cursor timestamp") 155 } 156 157 // Validate URI format (must be AT-URI) 158 if !strings.HasPrefix(uri, "at://") { 159 return "", nil, fmt.Errorf("invalid cursor URI") 160 } 161 162 filter := fmt.Sprintf(`AND (p.created_at < $%d OR (p.created_at = $%d AND p.uri < $%d))`, 163 paramOffset, paramOffset, paramOffset+1) 164 return filter, []interface{}{createdAt, uri}, nil 165 166 case "top": 167 // Cursor format: score::timestamp::uri 168 if len(payloadParts) != 3 { 169 return "", nil, fmt.Errorf("invalid cursor format for %s sort", sort) 170 } 171 172 scoreStr := payloadParts[0] 173 createdAt := payloadParts[1] 174 uri := payloadParts[2] 175 176 // Validate score is numeric 177 score := 0 178 if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil { 179 return "", nil, fmt.Errorf("invalid cursor score") 180 } 181 182 // Validate timestamp format 183 if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { 184 return "", nil, fmt.Errorf("invalid cursor timestamp") 185 } 186 187 // Validate URI format (must be AT-URI) 188 if !strings.HasPrefix(uri, "at://") { 189 return "", nil, fmt.Errorf("invalid cursor URI") 190 } 191 192 filter := fmt.Sprintf(`AND (p.score < $%d OR (p.score = $%d AND p.created_at < $%d) OR (p.score = $%d AND p.created_at = $%d AND p.uri < $%d))`, 193 paramOffset, paramOffset, paramOffset+1, paramOffset, paramOffset+1, paramOffset+2) 194 return filter, []interface{}{score, createdAt, uri}, nil 195 196 case "hot": 197 // Cursor format: hot_rank::timestamp::uri 198 // CRITICAL: Must use computed hot_rank, not raw score, to prevent pagination bugs 199 if len(payloadParts) != 3 { 200 return "", nil, fmt.Errorf("invalid cursor format for hot sort") 201 } 202 203 hotRankStr := payloadParts[0] 204 createdAt := payloadParts[1] 205 uri := payloadParts[2] 206 207 // Validate hot_rank is numeric (float) 208 hotRank := 0.0 209 if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil { 210 return "", nil, fmt.Errorf("invalid cursor hot rank") 211 } 212 213 // Validate timestamp format 214 if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { 215 return "", nil, fmt.Errorf("invalid cursor timestamp") 216 } 217 218 // Validate URI format (must be AT-URI) 219 if !strings.HasPrefix(uri, "at://") { 220 return "", nil, fmt.Errorf("invalid cursor URI") 221 } 222 223 // CRITICAL: Compare against the computed hot_rank expression, not p.score 224 filter := fmt.Sprintf(`AND ((%s < $%d OR (%s = $%d AND p.created_at < $%d) OR (%s = $%d AND p.created_at = $%d AND p.uri < $%d)) AND p.uri != $%d)`, 225 r.hotRankExpression, paramOffset, 226 r.hotRankExpression, paramOffset, paramOffset+1, 227 r.hotRankExpression, paramOffset, paramOffset+1, paramOffset+2, 228 paramOffset+3) 229 return filter, []interface{}{hotRank, createdAt, uri, uri}, nil 230 231 default: 232 return "", nil, nil 233 } 234} 235 236// buildCursor creates HMAC-signed pagination cursor from last post 237// SECURITY: Cursor is signed with HMAC-SHA256 to prevent manipulation 238func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64) string { 239 var payload string 240 // Use :: as delimiter following Bluesky convention 241 const delimiter = "::" 242 243 switch sort { 244 case "new": 245 // Format: timestamp::uri 246 payload = fmt.Sprintf("%s%s%s", post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI) 247 248 case "top": 249 // Format: score::timestamp::uri 250 score := 0 251 if post.Stats != nil { 252 score = post.Stats.Score 253 } 254 payload = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI) 255 256 case "hot": 257 // Format: hot_rank::timestamp::uri 258 // CRITICAL: Use computed hot_rank with full precision 259 hotRankStr := strconv.FormatFloat(hotRank, 'g', -1, 64) 260 payload = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI) 261 262 default: 263 payload = post.URI 264 } 265 266 // Sign the payload with HMAC-SHA256 267 mac := hmac.New(sha256.New, []byte(r.cursorSecret)) 268 mac.Write([]byte(payload)) 269 signature := hex.EncodeToString(mac.Sum(nil)) 270 271 // Append signature to payload 272 signed := payload + delimiter + signature 273 274 return base64.StdEncoding.EncodeToString([]byte(signed)) 275} 276 277// scanFeedPost scans a database row into a PostView 278// This is the shared scanning logic used by both timeline and discover feeds 279func (r *feedRepoBase) scanFeedPost(rows *sql.Rows) (*posts.PostView, float64, error) { 280 var ( 281 postView posts.PostView 282 authorView posts.AuthorView 283 communityRef posts.CommunityRef 284 title, content sql.NullString 285 facets, embed sql.NullString 286 labels pq.StringArray 287 editedAt sql.NullTime 288 communityAvatar sql.NullString 289 hotRank sql.NullFloat64 290 ) 291 292 err := rows.Scan( 293 &postView.URI, &postView.CID, &postView.RKey, 294 &authorView.DID, &authorView.Handle, 295 &communityRef.DID, &communityRef.Name, &communityAvatar, 296 &title, &content, &facets, &embed, &labels, 297 &postView.CreatedAt, &editedAt, &postView.IndexedAt, 298 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 299 &hotRank, 300 ) 301 if err != nil { 302 return nil, 0, err 303 } 304 305 // Build author view 306 postView.Author = &authorView 307 308 // Build community ref 309 communityRef.Avatar = nullStringPtr(communityAvatar) 310 postView.Community = &communityRef 311 312 // Set optional fields 313 postView.Title = nullStringPtr(title) 314 postView.Text = nullStringPtr(content) 315 316 // Parse facets JSON 317 if facets.Valid { 318 var facetArray []interface{} 319 if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil { 320 postView.TextFacets = facetArray 321 } 322 } 323 324 // Parse embed JSON 325 if embed.Valid { 326 var embedData interface{} 327 if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { 328 postView.Embed = embedData 329 } 330 } 331 332 // Build stats 333 postView.Stats = &posts.PostStats{ 334 Upvotes: postView.UpvoteCount, 335 Downvotes: postView.DownvoteCount, 336 Score: postView.Score, 337 CommentCount: postView.CommentCount, 338 } 339 340 // Build the record (required by lexicon) 341 record := map[string]interface{}{ 342 "$type": "social.coves.post.record", 343 "community": communityRef.DID, 344 "author": authorView.DID, 345 "createdAt": postView.CreatedAt.Format(time.RFC3339), 346 } 347 348 // Add optional fields to record if present 349 if title.Valid { 350 record["title"] = title.String 351 } 352 if content.Valid { 353 record["content"] = content.String 354 } 355 if facets.Valid { 356 var facetArray []interface{} 357 if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil { 358 record["facets"] = facetArray 359 } 360 } 361 if embed.Valid { 362 var embedData interface{} 363 if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { 364 record["embed"] = embedData 365 } 366 } 367 if len(labels) > 0 { 368 record["contentLabels"] = labels 369 } 370 371 postView.Record = record 372 373 // Return the computed hot_rank (0.0 if NULL for non-hot sorts) 374 hotRankValue := 0.0 375 if hotRank.Valid { 376 hotRankValue = hotRank.Float64 377 } 378 379 return &postView, hotRankValue, nil 380}