an atproto based link aggregator

Refactor: Extract shared code and add security improvements

Security fixes:
- Add domain format validation and LIKE escaping in /from/[domain]
- Add AT URI format validation in vote API (format, length, collection match)
- Add in-memory rate limiting for API endpoints (60/min vote, 30/min search)

Code deduplication:
- Extract post queries to src/lib/server/queries/posts.ts
- Extract enrichment logic to src/lib/server/enrichment.ts
- Extract FTS5 sanitization to src/lib/server/search/sanitize.ts

Files reduced:
- src/routes/+page.server.ts: 69 -> 23 lines
- src/routes/new/+page.server.ts: 59 -> 20 lines
- src/routes/from/[domain]/+page.server.ts: 65 -> 38 lines

New test coverage: 64 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+673 -181
+37 -1
src/hooks.server.ts
··· 1 - import type { Handle } from '@sveltejs/kit'; 1 + import { json, type Handle } from '@sveltejs/kit'; 2 2 import { getCurrentDid } from '$lib/server/auth'; 3 + import { 4 + checkRateLimit, 5 + getRateLimitKey, 6 + getRateLimitConfig 7 + } from '$lib/server/rate-limit'; 3 8 4 9 // Ingester runs as a separate process on the LiteFS primary machine 5 10 // See src/ingester/main.ts and fly.ingester.toml ··· 8 13 // Load the current user's DID from session cookie 9 14 const did = await getCurrentDid(event.cookies); 10 15 event.locals.did = did; 16 + 17 + // Apply rate limiting to API routes 18 + const path = event.url.pathname; 19 + if (path.startsWith('/api/')) { 20 + const ip = event.getClientAddress(); 21 + const key = getRateLimitKey(ip, path, did); 22 + const config = getRateLimitConfig(path); 23 + const { allowed, remaining, resetAt } = checkRateLimit(key, config); 24 + 25 + if (!allowed) { 26 + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); 27 + return new Response( 28 + JSON.stringify({ error: 'Too many requests' }), 29 + { 30 + status: 429, 31 + headers: { 32 + 'Content-Type': 'application/json', 33 + 'Retry-After': String(retryAfter), 34 + 'X-RateLimit-Remaining': '0', 35 + 'X-RateLimit-Reset': String(resetAt) 36 + } 37 + } 38 + ); 39 + } 40 + 41 + // Add rate limit headers to response 42 + const response = await resolve(event); 43 + response.headers.set('X-RateLimit-Remaining', String(remaining)); 44 + response.headers.set('X-RateLimit-Reset', String(resetAt)); 45 + return response; 46 + } 11 47 12 48 return resolve(event); 13 49 };
+114
src/lib/server/enrichment.ts
··· 1 + /** 2 + * Shared enrichment functions for posts and comments. 3 + * Adds author profiles, vote counts, and user votes to entities. 4 + */ 5 + 6 + import { fetchProfiles, getProfileOrFallback } from './profiles'; 7 + import { getVoteCounts, getUserVotes } from './vote-counts'; 8 + import { calculateHotScore } from '$lib/utils/ranking'; 9 + import type { AuthorProfile } from '$lib/types'; 10 + 11 + /** Base post fields from database */ 12 + export interface PostBase { 13 + uri: string; 14 + cid: string; 15 + authorDid: string; 16 + rkey: string; 17 + url: string | null; 18 + title: string; 19 + text: string | null; 20 + createdAt: string; 21 + indexedAt: string; 22 + commentCount: number; 23 + } 24 + 25 + /** Base comment fields from database */ 26 + export interface CommentBase { 27 + uri: string; 28 + cid: string; 29 + authorDid: string; 30 + rkey: string; 31 + postUri: string; 32 + parentUri: string | null; 33 + text: string; 34 + createdAt: string; 35 + indexedAt: string; 36 + } 37 + 38 + /** Post with all enrichment data */ 39 + export interface EnrichedPost extends PostBase { 40 + voteCount: number; 41 + author: AuthorProfile; 42 + userVote: number; 43 + hotScore: number; 44 + } 45 + 46 + /** Comment with all enrichment data */ 47 + export interface EnrichedComment extends CommentBase { 48 + voteCount: number; 49 + author: AuthorProfile; 50 + userVote: number; 51 + hotScore: number; 52 + } 53 + 54 + /** 55 + * Enrich posts with author profiles, vote counts, user votes, and hot scores. 56 + */ 57 + export async function enrichPosts( 58 + posts: PostBase[], 59 + userDid?: string | null 60 + ): Promise<EnrichedPost[]> { 61 + if (posts.length === 0) return []; 62 + 63 + // Fetch all data in parallel 64 + const authorDids = posts.map((p) => p.authorDid); 65 + const postUris = posts.map((p) => p.uri); 66 + 67 + const [profiles, voteCounts, userVotes] = await Promise.all([ 68 + fetchProfiles(authorDids), 69 + getVoteCounts(postUris), 70 + userDid ? getUserVotes(userDid, postUris) : Promise.resolve(new Map<string, number>()) 71 + ]); 72 + 73 + return posts.map((post) => { 74 + const voteCount = voteCounts.get(post.uri) ?? 0; 75 + return { 76 + ...post, 77 + voteCount, 78 + author: getProfileOrFallback(profiles, post.authorDid), 79 + userVote: userVotes.get(post.uri) ?? 0, 80 + hotScore: calculateHotScore(voteCount, post.createdAt) 81 + }; 82 + }); 83 + } 84 + 85 + /** 86 + * Enrich comments with author profiles, vote counts, user votes, and hot scores. 87 + */ 88 + export async function enrichComments( 89 + comments: CommentBase[], 90 + userDid?: string | null 91 + ): Promise<EnrichedComment[]> { 92 + if (comments.length === 0) return []; 93 + 94 + // Fetch all data in parallel 95 + const authorDids = comments.map((c) => c.authorDid); 96 + const commentUris = comments.map((c) => c.uri); 97 + 98 + const [profiles, voteCounts, userVotes] = await Promise.all([ 99 + fetchProfiles(authorDids), 100 + getVoteCounts(commentUris), 101 + userDid ? getUserVotes(userDid, commentUris) : Promise.resolve(new Map<string, number>()) 102 + ]); 103 + 104 + return comments.map((comment) => { 105 + const voteCount = voteCounts.get(comment.uri) ?? 0; 106 + return { 107 + ...comment, 108 + voteCount, 109 + author: getProfileOrFallback(profiles, comment.authorDid), 110 + userVote: userVotes.get(comment.uri) ?? 0, 111 + hotScore: calculateHotScore(voteCount, comment.createdAt) 112 + }; 113 + }); 114 + }
+101
src/lib/server/queries/posts.ts
··· 1 + /** 2 + * Shared post query functions. 3 + * Centralizes post fetching logic to avoid duplication. 4 + */ 5 + 6 + import { contentDb } from '$lib/server/db'; 7 + import { posts, comments } from '$lib/server/db/schema'; 8 + import { desc, eq, count, sql, type SQL } from 'drizzle-orm'; 9 + import type { PostBase } from '../enrichment'; 10 + 11 + export const DEFAULT_PAGE_SIZE = 30; 12 + 13 + /** Options for fetching posts */ 14 + export interface GetPostsOptions { 15 + /** Number of posts to fetch (default: 30) */ 16 + limit?: number; 17 + /** Offset for pagination */ 18 + offset?: number; 19 + /** Additional WHERE clause */ 20 + where?: SQL; 21 + /** Order by field (default: createdAt desc) */ 22 + orderBy?: 'createdAt' | 'indexedAt'; 23 + } 24 + 25 + /** 26 + * Fetch posts with comment counts. 27 + * This is the base query used by all post listing pages. 28 + */ 29 + export async function getPostsWithCommentCount( 30 + options: GetPostsOptions = {} 31 + ): Promise<PostBase[]> { 32 + const { limit = DEFAULT_PAGE_SIZE, offset = 0, where, orderBy = 'createdAt' } = options; 33 + 34 + let query = contentDb 35 + .select({ 36 + uri: posts.uri, 37 + cid: posts.cid, 38 + authorDid: posts.authorDid, 39 + rkey: posts.rkey, 40 + url: posts.url, 41 + title: posts.title, 42 + text: posts.text, 43 + createdAt: posts.createdAt, 44 + indexedAt: posts.indexedAt, 45 + commentCount: count(comments.uri) 46 + }) 47 + .from(posts) 48 + .leftJoin(comments, eq(comments.postUri, posts.uri)) 49 + .groupBy(posts.uri) 50 + .orderBy(orderBy === 'createdAt' ? desc(posts.createdAt) : desc(posts.indexedAt)) 51 + .limit(limit) 52 + .offset(offset); 53 + 54 + if (where) { 55 + query = query.where(where) as typeof query; 56 + } 57 + 58 + return query; 59 + } 60 + 61 + /** 62 + * Fetch posts with pagination support. 63 + * Returns posts and whether there are more pages. 64 + */ 65 + export async function getPostsPaginated( 66 + options: GetPostsOptions = {} 67 + ): Promise<{ posts: PostBase[]; hasMore: boolean }> { 68 + const limit = options.limit ?? DEFAULT_PAGE_SIZE; 69 + 70 + // Fetch one extra to check if there are more 71 + const allPosts = await getPostsWithCommentCount({ 72 + ...options, 73 + limit: limit + 1 74 + }); 75 + 76 + const hasMore = allPosts.length > limit; 77 + const postsPage = hasMore ? allPosts.slice(0, limit) : allPosts; 78 + 79 + return { posts: postsPage, hasMore }; 80 + } 81 + 82 + /** 83 + * Parse and validate page number from URL params. 84 + * Returns a valid page number (minimum 1, maximum 1000). 85 + */ 86 + export function parsePageParam(searchParams: URLSearchParams): number { 87 + const pageStr = searchParams.get('page') || '1'; 88 + const page = parseInt(pageStr, 10); 89 + 90 + if (isNaN(page) || page < 1) return 1; 91 + if (page > 1000) return 1000; 92 + 93 + return page; 94 + } 95 + 96 + /** 97 + * Calculate offset from page number. 98 + */ 99 + export function getOffset(page: number, pageSize: number = DEFAULT_PAGE_SIZE): number { 100 + return (page - 1) * pageSize; 101 + }
+130
src/lib/server/rate-limit.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; 2 + import { checkRateLimit, getRateLimitKey, getRateLimitConfig, RATE_LIMITS } from './rate-limit'; 3 + 4 + describe('Rate Limiter', () => { 5 + beforeEach(() => { 6 + vi.useFakeTimers(); 7 + }); 8 + 9 + afterEach(() => { 10 + vi.useRealTimers(); 11 + }); 12 + 13 + describe('checkRateLimit', () => { 14 + it('should allow requests under the limit', () => { 15 + const config = { max: 3, windowMs: 1000 }; 16 + const key = 'test-user-1'; 17 + 18 + const r1 = checkRateLimit(key, config); 19 + expect(r1.allowed).toBe(true); 20 + expect(r1.remaining).toBe(2); 21 + 22 + const r2 = checkRateLimit(key, config); 23 + expect(r2.allowed).toBe(true); 24 + expect(r2.remaining).toBe(1); 25 + 26 + const r3 = checkRateLimit(key, config); 27 + expect(r3.allowed).toBe(true); 28 + expect(r3.remaining).toBe(0); 29 + }); 30 + 31 + it('should block requests over the limit', () => { 32 + const config = { max: 2, windowMs: 1000 }; 33 + const key = 'test-user-2'; 34 + 35 + checkRateLimit(key, config); 36 + checkRateLimit(key, config); 37 + 38 + const r3 = checkRateLimit(key, config); 39 + expect(r3.allowed).toBe(false); 40 + expect(r3.remaining).toBe(0); 41 + }); 42 + 43 + it('should reset after window expires', () => { 44 + const config = { max: 2, windowMs: 1000 }; 45 + const key = 'test-user-3'; 46 + 47 + checkRateLimit(key, config); 48 + checkRateLimit(key, config); 49 + 50 + // Should be rate limited 51 + expect(checkRateLimit(key, config).allowed).toBe(false); 52 + 53 + // Advance time past window 54 + vi.advanceTimersByTime(1001); 55 + 56 + // Should be allowed again 57 + const result = checkRateLimit(key, config); 58 + expect(result.allowed).toBe(true); 59 + expect(result.remaining).toBe(1); 60 + }); 61 + 62 + it('should track different keys independently', () => { 63 + const config = { max: 1, windowMs: 1000 }; 64 + 65 + checkRateLimit('user-a', config); 66 + checkRateLimit('user-b', config); 67 + 68 + // Both hit their limits 69 + expect(checkRateLimit('user-a', config).allowed).toBe(false); 70 + expect(checkRateLimit('user-b', config).allowed).toBe(false); 71 + 72 + // New user is allowed 73 + expect(checkRateLimit('user-c', config).allowed).toBe(true); 74 + }); 75 + }); 76 + 77 + describe('getRateLimitKey', () => { 78 + it('should use userDid when available', () => { 79 + const key = getRateLimitKey('192.168.1.1', '/api/vote', 'did:plc:test'); 80 + expect(key).toBe('vote:did:plc:test'); 81 + }); 82 + 83 + it('should fall back to IP when no userDid', () => { 84 + const key = getRateLimitKey('192.168.1.1', '/api/vote', null); 85 + expect(key).toBe('vote:192.168.1.1'); 86 + }); 87 + 88 + it('should use unknown when no IP or userDid', () => { 89 + const key = getRateLimitKey(null, '/api/vote', null); 90 + expect(key).toBe('vote:unknown'); 91 + }); 92 + 93 + it('should create different keys for different endpoints', () => { 94 + const ip = '192.168.1.1'; 95 + expect(getRateLimitKey(ip, '/api/vote', null)).toBe('vote:192.168.1.1'); 96 + expect(getRateLimitKey(ip, '/api/search', null)).toBe('search:192.168.1.1'); 97 + expect(getRateLimitKey(ip, '/api/other', null)).toBe('api:192.168.1.1'); 98 + }); 99 + }); 100 + 101 + describe('getRateLimitConfig', () => { 102 + it('should return vote config for vote endpoints', () => { 103 + expect(getRateLimitConfig('/api/vote')).toBe(RATE_LIMITS.vote); 104 + expect(getRateLimitConfig('/api/vote/123')).toBe(RATE_LIMITS.vote); 105 + }); 106 + 107 + it('should return search config for search endpoints', () => { 108 + expect(getRateLimitConfig('/api/search')).toBe(RATE_LIMITS.search); 109 + expect(getRateLimitConfig('/api/search?q=test')).toBe(RATE_LIMITS.search); 110 + }); 111 + 112 + it('should return api config for other endpoints', () => { 113 + expect(getRateLimitConfig('/api/other')).toBe(RATE_LIMITS.api); 114 + expect(getRateLimitConfig('/api/comments')).toBe(RATE_LIMITS.api); 115 + }); 116 + }); 117 + 118 + describe('RATE_LIMITS config', () => { 119 + it('should have reasonable defaults', () => { 120 + expect(RATE_LIMITS.vote.max).toBe(60); 121 + expect(RATE_LIMITS.vote.windowMs).toBe(60_000); 122 + 123 + expect(RATE_LIMITS.search.max).toBe(30); 124 + expect(RATE_LIMITS.search.windowMs).toBe(60_000); 125 + 126 + expect(RATE_LIMITS.api.max).toBe(100); 127 + expect(RATE_LIMITS.api.windowMs).toBe(60_000); 128 + }); 129 + }); 130 + });
+104
src/lib/server/rate-limit.ts
··· 1 + /** 2 + * Simple in-memory sliding window rate limiter. 3 + * Works well for single-instance deployments. 4 + * For multi-instance, use Redis-based rate limiting. 5 + */ 6 + 7 + interface RateLimitEntry { 8 + count: number; 9 + resetAt: number; 10 + } 11 + 12 + interface RateLimitConfig { 13 + /** Max requests per window */ 14 + max: number; 15 + /** Window size in milliseconds */ 16 + windowMs: number; 17 + } 18 + 19 + // Store: key -> { count, resetAt } 20 + const store = new Map<string, RateLimitEntry>(); 21 + 22 + // Cleanup old entries every 60 seconds 23 + setInterval(() => { 24 + const now = Date.now(); 25 + for (const [key, entry] of store) { 26 + if (entry.resetAt < now) { 27 + store.delete(key); 28 + } 29 + } 30 + }, 60_000); 31 + 32 + /** 33 + * Check if a request should be rate limited. 34 + * @returns Object with allowed status and remaining requests 35 + */ 36 + export function checkRateLimit( 37 + key: string, 38 + config: RateLimitConfig 39 + ): { allowed: boolean; remaining: number; resetAt: number } { 40 + const now = Date.now(); 41 + const entry = store.get(key); 42 + 43 + // No existing entry or expired window 44 + if (!entry || entry.resetAt < now) { 45 + const resetAt = now + config.windowMs; 46 + store.set(key, { count: 1, resetAt }); 47 + return { allowed: true, remaining: config.max - 1, resetAt }; 48 + } 49 + 50 + // Within window 51 + if (entry.count < config.max) { 52 + entry.count++; 53 + return { allowed: true, remaining: config.max - entry.count, resetAt: entry.resetAt }; 54 + } 55 + 56 + // Rate limited 57 + return { allowed: false, remaining: 0, resetAt: entry.resetAt }; 58 + } 59 + 60 + /** 61 + * Create a rate limit key from request info. 62 + * Uses IP address + optional path prefix. 63 + */ 64 + export function getRateLimitKey( 65 + ip: string | null, 66 + path: string, 67 + userDid?: string | null 68 + ): string { 69 + // Prefer user DID for authenticated requests (more accurate) 70 + const identifier = userDid || ip || 'unknown'; 71 + 72 + // Group by API endpoint type 73 + if (path.startsWith('/api/vote')) { 74 + return `vote:${identifier}`; 75 + } 76 + if (path.startsWith('/api/search')) { 77 + return `search:${identifier}`; 78 + } 79 + 80 + return `api:${identifier}`; 81 + } 82 + 83 + // Default rate limit configs 84 + export const RATE_LIMITS = { 85 + // Vote: 60 requests per minute (generous for normal use) 86 + vote: { max: 60, windowMs: 60_000 }, 87 + // Search: 30 requests per minute 88 + search: { max: 30, windowMs: 60_000 }, 89 + // General API: 100 requests per minute 90 + api: { max: 100, windowMs: 60_000 } 91 + } as const; 92 + 93 + /** 94 + * Get the appropriate rate limit config for a path. 95 + */ 96 + export function getRateLimitConfig(path: string): RateLimitConfig { 97 + if (path.startsWith('/api/vote')) { 98 + return RATE_LIMITS.vote; 99 + } 100 + if (path.startsWith('/api/search')) { 101 + return RATE_LIMITS.search; 102 + } 103 + return RATE_LIMITS.api; 104 + }
+43
src/lib/server/search/sanitize.ts
··· 1 + /** 2 + * FTS5 query sanitization. 3 + * Removes special characters and operators to prevent injection attacks. 4 + */ 5 + 6 + /** 7 + * Sanitize a user query for use with SQLite FTS5. 8 + * 9 + * Removes: 10 + * - Quotes (single and double) 11 + * - Boolean operators (OR, AND, NOT, NEAR) 12 + * - Special FTS5 characters (* - : ^ ( ) { } [ ] = < > ! @ # $ % & | \) 13 + * 14 + * Then wraps each term in quotes with prefix matching. 15 + * 16 + * @returns Sanitized FTS5 query string, or empty string if no valid terms 17 + */ 18 + export function sanitizeFtsQuery(query: string): string { 19 + // Remove FTS5 special operators and characters 20 + const sanitized = query 21 + .replace(/['"]/g, '') // Remove quotes 22 + .replace(/\b(OR|AND|NOT|NEAR)\b/gi, '') // Remove boolean operators 23 + .replace(/[*\-:^(){}[\]=<>!@#$%&|\\]/g, '') // Remove special chars 24 + .trim(); 25 + 26 + const terms = sanitized 27 + .split(/\s+/) 28 + .filter((term) => term.length > 0); 29 + 30 + if (terms.length === 0) { 31 + return ''; 32 + } 33 + 34 + // Wrap each term in quotes with prefix matching 35 + return terms.map((term) => `"${term}"*`).join(' '); 36 + } 37 + 38 + /** 39 + * Check if a query is valid (has at least 2 characters). 40 + */ 41 + export function isValidSearchQuery(query: string | null | undefined): query is string { 42 + return !!query && query.trim().length >= 2; 43 + }
+10 -56
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 - import { contentDb } from '$lib/server/db'; 3 - import { posts, comments } from '$lib/server/db/schema'; 4 - import { desc, eq, count } from 'drizzle-orm'; 5 - import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 - import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 - import { calculateHotScore } from '$lib/utils/ranking'; 8 - 9 - const PAGE_SIZE = 30; 2 + import { getPostsPaginated, parsePageParam, getOffset } from '$lib/server/queries/posts'; 3 + import { enrichPosts } from '$lib/server/enrichment'; 10 4 11 5 export const load: PageServerLoad = async ({ locals, url }) => { 12 - const page = parseInt(url.searchParams.get('page') || '1'); 13 - const offset = (page - 1) * PAGE_SIZE; 14 - 15 - // Fetch recent posts with comment counts from content DB 16 - const recentPosts = await contentDb 17 - .select({ 18 - uri: posts.uri, 19 - cid: posts.cid, 20 - authorDid: posts.authorDid, 21 - rkey: posts.rkey, 22 - url: posts.url, 23 - title: posts.title, 24 - text: posts.text, 25 - createdAt: posts.createdAt, 26 - indexedAt: posts.indexedAt, 27 - commentCount: count(comments.uri) 28 - }) 29 - .from(posts) 30 - .leftJoin(comments, eq(comments.postUri, posts.uri)) 31 - .groupBy(posts.uri) 32 - .orderBy(desc(posts.createdAt)) 33 - .limit(PAGE_SIZE + 1) // Fetch one extra to check if there are more 34 - .offset(offset); 6 + const page = parsePageParam(url.searchParams); 7 + const offset = getOffset(page); 35 8 36 - // Fetch author profiles for all posts 37 - const authorDids = recentPosts.map((p) => p.authorDid); 38 - const profiles = await fetchProfiles(authorDids); 9 + // Fetch posts with comment counts 10 + const { posts, hasMore } = await getPostsPaginated({ offset }); 39 11 40 - // Get vote counts and user votes from local DB 41 - const postUris = recentPosts.map((p) => p.uri); 42 - const voteCounts = await getVoteCounts(postUris); 43 - const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 12 + // Enrich with profiles, votes, and hot scores 13 + const enrichedPosts = await enrichPosts(posts, locals.did); 44 14 45 - // Check if there are more posts 46 - const hasMore = recentPosts.length > PAGE_SIZE; 47 - const postsToProcess = hasMore ? recentPosts.slice(0, PAGE_SIZE) : recentPosts; 48 - 49 - // Combine posts with author profiles, vote counts, user votes, and hot score 50 - const postsWithData = postsToProcess.map((post) => { 51 - const voteCount = voteCounts.get(post.uri) ?? 0; 52 - return { 53 - ...post, 54 - voteCount, 55 - author: getProfileOrFallback(profiles, post.authorDid), 56 - userVote: userVotes.get(post.uri) ?? 0, 57 - hotScore: calculateHotScore(voteCount, post.createdAt) 58 - }; 59 - }); 60 - 61 - // Sort by hot score (descending) 62 - const hotPosts = postsWithData.sort((a, b) => b.hotScore - a.hotScore); 15 + // Sort by hot score (descending) for home page 16 + const hotPosts = enrichedPosts.sort((a, b) => b.hotScore - a.hotScore); 63 17 64 18 return { 65 19 posts: hotPosts,
+3 -13
src/routes/api/search/+server.ts
··· 2 2 import type { RequestHandler } from './$types'; 3 3 import { contentClient } from '$lib/server/db'; 4 4 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 5 + import { sanitizeFtsQuery, isValidSearchQuery } from '$lib/server/search/sanitize'; 5 6 6 7 export const GET: RequestHandler = async ({ url }) => { 7 8 const query = url.searchParams.get('q')?.trim(); 8 9 const type = url.searchParams.get('type') || 'posts'; // 'posts' | 'comments' | 'all' 9 10 const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100); 10 11 11 - if (!query || query.length < 2) { 12 + if (!isValidSearchQuery(query)) { 12 13 return json({ posts: [], comments: [], error: 'Query must be at least 2 characters' }); 13 14 } 14 15 15 - // Sanitize FTS5 query - remove operators and special characters 16 - const sanitized = query 17 - .replace(/['"]/g, '') // Remove quotes 18 - .replace(/\b(OR|AND|NOT|NEAR)\b/gi, '') // Remove boolean operators 19 - .replace(/[*\-:^(){}[\]=<>!@#$%&|\\]/g, '') // Remove special chars 20 - .trim(); 21 - 22 - const ftsQuery = sanitized 23 - .split(/\s+/) 24 - .filter((term) => term.length > 0) 25 - .map((term) => `"${term}"*`) // Prefix match each term 26 - .join(' '); 16 + const ftsQuery = sanitizeFtsQuery(query); 27 17 28 18 // If all characters were stripped, return empty results 29 19 if (!ftsQuery) {
+3 -15
src/routes/api/search/search.spec.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 - 3 - // Test the FTS5 query sanitization logic - mirrors the actual implementation 4 - function sanitizeForFts(query: string): string { 5 - // Remove FTS5 special operators and characters 6 - const sanitized = query 7 - .replace(/['"]/g, '') // Remove quotes 8 - .replace(/\b(OR|AND|NOT|NEAR)\b/gi, '') // Remove boolean operators 9 - .replace(/[*\-:^(){}[\]=<>!@#$%&|\\]/g, '') // Remove special chars 10 - .trim(); 2 + import { sanitizeFtsQuery } from '$lib/server/search/sanitize'; 11 3 12 - return sanitized 13 - .split(/\s+/) 14 - .filter((term) => term.length > 0) 15 - .map((term) => `"${term}"*`) // Prefix match each term 16 - .join(' '); 17 - } 4 + // Use the actual implementation for testing 5 + const sanitizeForFts = sanitizeFtsQuery; 18 6 19 7 describe('FTS5 query sanitization', () => { 20 8 it('should handle normal search terms', () => {
+18
src/routes/api/vote/+server.ts
··· 5 5 import { eq, and } from 'drizzle-orm'; 6 6 import { getCurrentDid } from '$lib/server/auth/session'; 7 7 8 + // AT URI format: at://did:method:id/collection/rkey 9 + const AT_URI_PATTERN = /^at:\/\/did:[a-z]+:[a-zA-Z0-9._-]+\/[a-z]+\.[a-z]+\.[a-z]+\/[a-zA-Z0-9._-]+$/; 10 + const MAX_URI_LENGTH = 256; 11 + 8 12 export const POST: RequestHandler = async ({ request, cookies }) => { 9 13 const userDid = await getCurrentDid(cookies); 10 14 if (!userDid) { ··· 19 23 return json({ error: 'targetUri is required' }, { status: 400 }); 20 24 } 21 25 26 + if (targetUri.length > MAX_URI_LENGTH) { 27 + return json({ error: 'targetUri too long' }, { status: 400 }); 28 + } 29 + 30 + if (!AT_URI_PATTERN.test(targetUri)) { 31 + return json({ error: 'Invalid targetUri format' }, { status: 400 }); 32 + } 33 + 22 34 if (targetType !== 'post' && targetType !== 'comment') { 23 35 return json({ error: 'targetType must be "post" or "comment"' }, { status: 400 }); 36 + } 37 + 38 + // Validate URI collection matches targetType 39 + const expectedCollection = targetType === 'post' ? 'one.papili.post' : 'one.papili.comment'; 40 + if (!targetUri.includes(`/${expectedCollection}/`)) { 41 + return json({ error: 'targetUri collection does not match targetType' }, { status: 400 }); 24 42 } 25 43 26 44 if (value !== 1 && value !== 0) {
+69 -1
src/routes/api/vote/vote.spec.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 2 3 - // Test vote API input validation logic 3 + // Constants matching the actual implementation 4 + const AT_URI_PATTERN = /^at:\/\/did:[a-z]+:[a-zA-Z0-9._-]+\/[a-z]+\.[a-z]+\.[a-z]+\/[a-zA-Z0-9._-]+$/; 5 + const MAX_URI_LENGTH = 256; 6 + 7 + // Test vote API input validation logic - mirrors actual implementation 4 8 describe('Vote API validation', () => { 5 9 // Helper to validate vote input 6 10 function validateVoteInput(body: { ··· 14 18 return { valid: false, error: 'targetUri is required' }; 15 19 } 16 20 21 + if (targetUri.length > MAX_URI_LENGTH) { 22 + return { valid: false, error: 'targetUri too long' }; 23 + } 24 + 25 + if (!AT_URI_PATTERN.test(targetUri)) { 26 + return { valid: false, error: 'Invalid targetUri format' }; 27 + } 28 + 17 29 if (targetType !== 'post' && targetType !== 'comment') { 18 30 return { valid: false, error: 'targetType must be "post" or "comment"' }; 31 + } 32 + 33 + // Validate URI collection matches targetType 34 + const expectedCollection = targetType === 'post' ? 'one.papili.post' : 'one.papili.comment'; 35 + if (!targetUri.includes(`/${expectedCollection}/`)) { 36 + return { valid: false, error: 'targetUri collection does not match targetType' }; 19 37 } 20 38 21 39 if (value !== 1 && value !== 0) { ··· 107 125 it('should handle empty object', () => { 108 126 const result = validateVoteInput({}); 109 127 expect(result.valid).toBe(false); 128 + }); 129 + 130 + it('should reject URI exceeding max length', () => { 131 + const longUri = 'at://did:plc:test/one.papili.post/' + 'a'.repeat(300); 132 + const result = validateVoteInput({ 133 + targetUri: longUri, 134 + targetType: 'post', 135 + value: 1 136 + }); 137 + expect(result.valid).toBe(false); 138 + expect(result.error).toBe('targetUri too long'); 139 + }); 140 + 141 + it('should reject malformed AT URI', () => { 142 + const malformedUris = [ 143 + 'https://example.com/post/123', 144 + 'at://invalid', 145 + 'at://did:plc:test', 146 + 'at://did:plc:test/post/123', // missing full collection name 147 + 'at://notadid/one.papili.post/123' 148 + ]; 149 + 150 + for (const uri of malformedUris) { 151 + const result = validateVoteInput({ 152 + targetUri: uri, 153 + targetType: 'post', 154 + value: 1 155 + }); 156 + expect(result.valid).toBe(false); 157 + } 158 + }); 159 + 160 + it('should reject mismatched collection and targetType', () => { 161 + // Post URI with comment targetType 162 + const result1 = validateVoteInput({ 163 + targetUri: 'at://did:plc:test/one.papili.post/123', 164 + targetType: 'comment', 165 + value: 1 166 + }); 167 + expect(result1.valid).toBe(false); 168 + expect(result1.error).toBe('targetUri collection does not match targetType'); 169 + 170 + // Comment URI with post targetType 171 + const result2 = validateVoteInput({ 172 + targetUri: 'at://did:plc:test/one.papili.comment/123', 173 + targetType: 'post', 174 + value: 1 175 + }); 176 + expect(result2.valid).toBe(false); 177 + expect(result2.error).toBe('targetUri collection does not match targetType'); 110 178 }); 111 179 }); 112 180
+26 -39
src/routes/from/[domain]/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 1 2 import type { PageServerLoad } from './$types'; 2 - import { contentDb } from '$lib/server/db'; 3 - import { posts, comments } from '$lib/server/db/schema'; 4 - import { desc, eq, count, like } from 'drizzle-orm'; 5 - import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 - import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 3 + import { sql } from 'drizzle-orm'; 4 + import { posts } from '$lib/server/db/schema'; 5 + import { getPostsWithCommentCount } from '$lib/server/queries/posts'; 6 + import { enrichPosts } from '$lib/server/enrichment'; 7 + 8 + // Valid domain pattern (alphanumeric, dots, hyphens) 9 + const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/i; 10 + 11 + // Escape LIKE special characters 12 + function escapeLikePattern(str: string): string { 13 + return str.replace(/[%_\\]/g, '\\$&'); 14 + } 7 15 8 16 export const load: PageServerLoad = async ({ locals, params }) => { 9 17 const domain = params.domain; 10 18 11 - // Fetch posts from this domain with comment counts from content DB 12 - const domainPosts = await contentDb 13 - .select({ 14 - uri: posts.uri, 15 - cid: posts.cid, 16 - authorDid: posts.authorDid, 17 - rkey: posts.rkey, 18 - url: posts.url, 19 - title: posts.title, 20 - text: posts.text, 21 - createdAt: posts.createdAt, 22 - indexedAt: posts.indexedAt, 23 - commentCount: count(comments.uri) 24 - }) 25 - .from(posts) 26 - .leftJoin(comments, eq(comments.postUri, posts.uri)) 27 - .where(like(posts.url, `%://${domain}%`)) 28 - .groupBy(posts.uri) 29 - .orderBy(desc(posts.createdAt)) 30 - .limit(50); 19 + // Validate domain format to prevent injection 20 + if (!domain || domain.length > 253 || !DOMAIN_PATTERN.test(domain)) { 21 + error(400, 'Invalid domain format'); 22 + } 31 23 32 - const authorDids = domainPosts.map((p) => p.authorDid); 33 - const profiles = await fetchProfiles(authorDids); 34 - 35 - // Get vote counts and user votes from local DB 36 - const postUris = domainPosts.map((p) => p.uri); 37 - const voteCounts = await getVoteCounts(postUris); 38 - const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 24 + // Fetch posts from this domain 25 + const whereClause = sql`${posts.url} LIKE ${'%://' + escapeLikePattern(domain) + '%'} ESCAPE '\\'`; 26 + const domainPosts = await getPostsWithCommentCount({ 27 + where: whereClause, 28 + limit: 50 29 + }); 39 30 40 - const postsWithData = domainPosts.map((post) => ({ 41 - ...post, 42 - voteCount: voteCounts.get(post.uri) ?? 0, 43 - author: getProfileOrFallback(profiles, post.authorDid), 44 - userVote: userVotes.get(post.uri) ?? 0 45 - })); 31 + // Enrich with profiles, votes, and hot scores 32 + const enrichedPosts = await enrichPosts(domainPosts, locals.did); 46 33 47 34 return { 48 35 domain, 49 - posts: postsWithData 36 + posts: enrichedPosts 50 37 }; 51 38 };
+9 -48
src/routes/new/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 - import { contentDb } from '$lib/server/db'; 3 - import { posts, comments } from '$lib/server/db/schema'; 4 - import { desc, eq, count } from 'drizzle-orm'; 5 - import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 - import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 - 8 - const PAGE_SIZE = 30; 2 + import { getPostsPaginated, parsePageParam, getOffset } from '$lib/server/queries/posts'; 3 + import { enrichPosts } from '$lib/server/enrichment'; 9 4 10 5 export const load: PageServerLoad = async ({ locals, url }) => { 11 - const page = parseInt(url.searchParams.get('page') || '1'); 12 - const offset = (page - 1) * PAGE_SIZE; 6 + const page = parsePageParam(url.searchParams); 7 + const offset = getOffset(page); 13 8 14 - // Fetch posts ordered by creation time (newest first) with comment counts 15 - const recentPosts = await contentDb 16 - .select({ 17 - uri: posts.uri, 18 - cid: posts.cid, 19 - authorDid: posts.authorDid, 20 - rkey: posts.rkey, 21 - url: posts.url, 22 - title: posts.title, 23 - text: posts.text, 24 - createdAt: posts.createdAt, 25 - indexedAt: posts.indexedAt, 26 - commentCount: count(comments.uri) 27 - }) 28 - .from(posts) 29 - .leftJoin(comments, eq(comments.postUri, posts.uri)) 30 - .groupBy(posts.uri) 31 - .orderBy(desc(posts.createdAt)) 32 - .limit(PAGE_SIZE + 1) 33 - .offset(offset); 9 + // Fetch posts ordered by creation time (newest first) 10 + const { posts, hasMore } = await getPostsPaginated({ offset }); 34 11 35 - // Check if there are more posts 36 - const hasMore = recentPosts.length > PAGE_SIZE; 37 - const postsToProcess = hasMore ? recentPosts.slice(0, PAGE_SIZE) : recentPosts; 38 - 39 - const authorDids = postsToProcess.map((p) => p.authorDid); 40 - const profiles = await fetchProfiles(authorDids); 41 - 42 - // Get vote counts and user votes from local DB 43 - const postUris = postsToProcess.map((p) => p.uri); 44 - const voteCounts = await getVoteCounts(postUris); 45 - const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 46 - 47 - const postsWithData = postsToProcess.map((post) => ({ 48 - ...post, 49 - voteCount: voteCounts.get(post.uri) ?? 0, 50 - author: getProfileOrFallback(profiles, post.authorDid), 51 - userVote: userVotes.get(post.uri) ?? 0 52 - })); 12 + // Enrich with profiles, votes, and hot scores 13 + const enrichedPosts = await enrichPosts(posts, locals.did); 53 14 54 15 return { 55 - posts: postsWithData, 16 + posts: enrichedPosts, 56 17 page, 57 18 hasMore 58 19 };
+6 -8
src/routes/search/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { contentClient } from '$lib/server/db'; 3 3 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 4 + import { sanitizeFtsQuery, isValidSearchQuery } from '$lib/server/search/sanitize'; 4 5 import type { AuthorProfile } from '$lib/types'; 5 6 6 7 export interface SearchPost { ··· 37 38 const query = url.searchParams.get('q')?.trim() || ''; 38 39 const type = url.searchParams.get('type') || 'posts'; 39 40 40 - if (!query || query.length < 2) { 41 + if (!isValidSearchQuery(query)) { 41 42 return { query, type, posts: [] as SearchPost[], comments: [] as SearchComment[] }; 42 43 } 43 44 44 - // Escape special FTS5 characters and prepare query 45 - const ftsQuery = query 46 - .replace(/['"]/g, '') 47 - .split(/\s+/) 48 - .filter((term) => term.length > 0) 49 - .map((term) => `"${term}"*`) 50 - .join(' '); 45 + const ftsQuery = sanitizeFtsQuery(query); 46 + if (!ftsQuery) { 47 + return { query, type, posts: [] as SearchPost[], comments: [] as SearchComment[] }; 48 + } 51 49 52 50 const results: { posts: SearchPost[]; comments: SearchComment[] } = { posts: [], comments: [] }; 53 51