+1
docker-compose.yml
+1
docker-compose.yml
+1
-1
package.json
+1
-1
package.json
···
4
4
"type": "module",
5
5
"license": "MIT",
6
6
"scripts": {
7
-
"dev": "NODE_ENV=development tsx server/index.ts",
7
+
"dev": "./start-with-redis.sh",
8
8
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
9
9
"start": "npm run build && NODE_ENV=production node dist/index.js",
10
10
"check": "tsc",
+123
scripts/setup-oauth-keys.sh
+123
scripts/setup-oauth-keys.sh
···
1
+
#!/bin/bash
2
+
3
+
set -e
4
+
5
+
echo "🔐 OAuth Keyset Generator for AT Protocol AppView"
6
+
echo "=================================================="
7
+
echo ""
8
+
echo "This script generates OAuth signing keys for production use."
9
+
echo "Keys will be stored in oauth-keyset.json"
10
+
echo ""
11
+
12
+
# Check for required dependencies
13
+
MISSING_DEPS=()
14
+
for cmd in openssl xxd jq; do
15
+
if ! command -v $cmd &> /dev/null; then
16
+
MISSING_DEPS+=($cmd)
17
+
fi
18
+
done
19
+
20
+
if [ ${#MISSING_DEPS[@]} -ne 0 ]; then
21
+
echo "❌ Missing required dependencies: ${MISSING_DEPS[*]}"
22
+
echo ""
23
+
echo "Please install them:"
24
+
echo " Ubuntu/Debian: sudo apt-get install openssl xxd jq"
25
+
echo " RHEL/CentOS: sudo yum install openssl vim-common jq"
26
+
echo " macOS: brew install jq"
27
+
echo ""
28
+
exit 1
29
+
fi
30
+
31
+
32
+
if [ -f oauth-keyset.json ]; then
33
+
echo "⚠️ oauth-keyset.json already exists!"
34
+
read -p "Overwrite existing keys? (yes/no): " -r
35
+
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
36
+
echo "❌ Cancelled. Existing keys preserved."
37
+
exit 1
38
+
fi
39
+
fi
40
+
41
+
# Generate a unique Key ID (kid) - e.g., timestamp + random hex
42
+
KID="$(date +%s)-$(openssl rand -hex 4)"
43
+
echo "🔐 Generated Key ID (kid): ${KID}"
44
+
echo ""
45
+
46
+
echo "🔑 Generating ES256 key pair for OAuth..."
47
+
48
+
# Generate ES256 (P-256) private key
49
+
openssl ecparam -name prime256v1 -genkey -noout -out oauth-private.pem 2>/dev/null
50
+
51
+
# Extract public key
52
+
openssl ec -in oauth-private.pem -pubout -out oauth-public.pem 2>/dev/null
53
+
54
+
# Convert private key to PKCS8 format for easier handling
55
+
openssl pkcs8 -topk8 -nocrypt -in oauth-private.pem -out oauth-private-pkcs8.pem 2>/dev/null
56
+
57
+
# Read the keys (use PKCS8 format for private key)
58
+
PRIVATE_KEY=$(cat oauth-private-pkcs8.pem)
59
+
PUBLIC_KEY=$(cat oauth-public.pem)
60
+
61
+
# Extract private key components for JWK format
62
+
PRIVATE_D=$(openssl ec -in oauth-private.pem -text -noout 2>/dev/null | grep -A 3 'priv:' | tail -n +2 | tr -d ' \n:' | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=')
63
+
64
+
# Extract public key coordinates (x, y) from the public key
65
+
PUBLIC_KEY_HEX=$(openssl ec -in oauth-private.pem -pubout -text -noout 2>/dev/null | grep -A 5 'pub:' | tail -n +2 | tr -d ' \n:')
66
+
67
+
# Extract X and Y coordinates (each 32 bytes / 64 hex chars after the 04 prefix)
68
+
X_HEX=$(echo $PUBLIC_KEY_HEX | cut -c 3-66)
69
+
Y_HEX=$(echo $PUBLIC_KEY_HEX | cut -c 67-130)
70
+
71
+
# Convert to base64url
72
+
X_B64=$(echo $X_HEX | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=')
73
+
Y_B64=$(echo $Y_HEX | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=')
74
+
75
+
# Create OAuth keyset JSON file with both PEM and JWK formats
76
+
cat > oauth-keyset.json << EOF
77
+
{
78
+
"kid": "${KID}",
79
+
"privateKeyPem": $(echo "$PRIVATE_KEY" | jq -Rs .),
80
+
"publicKeyPem": $(echo "$PUBLIC_KEY" | jq -Rs .),
81
+
"jwk": {
82
+
"kid": "${KID}",
83
+
"kty": "EC",
84
+
"crv": "P-256",
85
+
"x": "${X_B64}",
86
+
"y": "${Y_B64}",
87
+
"d": "${PRIVATE_D}",
88
+
"alg": "ES256",
89
+
"use": "sig"
90
+
}
91
+
}
92
+
EOF
93
+
94
+
echo ""
95
+
echo "✅ OAuth keys generated successfully!"
96
+
echo ""
97
+
echo "📁 Files created:"
98
+
echo " - oauth-keyset.json (KEEP SECRET! Add to .gitignore)"
99
+
echo " - oauth-private.pem (KEEP SECRET! Add to .gitignore)"
100
+
echo " - oauth-public.pem (public key - safe to share)"
101
+
echo ""
102
+
echo "🔒 Security checklist:"
103
+
echo ""
104
+
echo "1. Add to .gitignore (if not already added):"
105
+
echo " echo 'oauth-keyset.json' >> .gitignore"
106
+
echo " echo 'oauth-private.pem' >> .gitignore"
107
+
echo ""
108
+
echo "2. Set environment variable for production:"
109
+
echo " export OAUTH_KEYSET_PATH=/path/to/oauth-keyset.json"
110
+
echo " Or add to your .env file:"
111
+
echo " OAUTH_KEYSET_PATH=/app/oauth-keyset.json"
112
+
echo ""
113
+
echo "3. Secure file permissions (on VPS):"
114
+
echo " chmod 600 oauth-keyset.json oauth-private.pem"
115
+
echo " chown appuser:appuser oauth-keyset.json oauth-private.pem"
116
+
echo ""
117
+
echo "4. For Docker deployment, mount as a volume:"
118
+
echo " volumes:"
119
+
echo " - ./oauth-keyset.json:/app/oauth-keyset.json:ro"
120
+
echo ""
121
+
echo "⚠️ NEVER commit these files to git!"
122
+
echo "⚠️ Store backups securely - losing these keys will invalidate all OAuth sessions!"
123
+
echo ""
+1
server/routes.ts
+1
server/routes.ts
···
2125
2125
app.get("/xrpc/app.bsky.unspecced.getPostThreadOtherV2", xrpcApi.getPostThreadOtherV2.bind(xrpcApi));
2126
2126
app.get("/xrpc/app.bsky.unspecced.getSuggestedUsers", xrpcApi.getSuggestedUsersUnspecced.bind(xrpcApi));
2127
2127
app.get("/xrpc/app.bsky.unspecced.getSuggestedFeeds", xrpcApi.getSuggestedFeedsUnspecced.bind(xrpcApi));
2128
+
app.get("/xrpc/app.bsky.unspecced.getPopularFeedGenerators", xrpcApi.getPopularFeedGenerators.bind(xrpcApi));
2128
2129
app.get("/xrpc/app.bsky.unspecced.getOnboardingSuggestedStarterPacks", xrpcApi.getOnboardingSuggestedStarterPacks.bind(xrpcApi));
2129
2130
app.get("/xrpc/app.bsky.unspecced.getTaggedSuggestions", xrpcApi.getTaggedSuggestions.bind(xrpcApi));
2130
2131
app.get("/xrpc/app.bsky.unspecced.getTrendingTopics", xrpcApi.getTrendingTopics.bind(xrpcApi));
+74
-1
server/services/event-processor.ts
+74
-1
server/services/event-processor.ts
···
5
5
import { pdsDataFetcher } from "./pds-data-fetcher";
6
6
import { smartConsole } from "./console-wrapper";
7
7
import { logAggregator } from "./log-aggregator";
8
-
import type { InsertUser, InsertPost, InsertLike, InsertRepost, InsertFollow, InsertBlock, InsertList, InsertListItem, InsertFeedGenerator, InsertStarterPack, InsertLabelerService, InsertFeedItem } from "@shared/schema";
8
+
import type { InsertUser, InsertPost, InsertLike, InsertRepost, InsertFollow, InsertBlock, InsertList, InsertListItem, InsertFeedGenerator, InsertStarterPack, InsertLabelerService, InsertFeedItem, InsertQuote, InsertVerification } from "@shared/schema";
9
9
10
10
function sanitizeText(text: string | undefined | null): string | undefined {
11
11
if (!text) return undefined;
···
735
735
case "com.atproto.label.label":
736
736
await this.processLabel(uri, repo, record);
737
737
break;
738
+
case "app.bsky.graph.verification":
739
+
await this.processVerification(uri, cid, repo, record);
740
+
break;
738
741
}
739
742
} else if (action === "delete") {
740
743
await this.processDelete(uri, collection);
···
890
893
}
891
894
} catch (error) {
892
895
smartConsole.error(`[NOTIFICATION] Error creating mention notifications:`, error);
896
+
}
897
+
898
+
// Handle quote posts (embed.record or embed.recordWithMedia)
899
+
try {
900
+
let quotedUri: string | null = null;
901
+
let quotedCid: string | null = null;
902
+
903
+
if (record.embed?.$type === 'app.bsky.embed.record') {
904
+
quotedUri = record.embed.record.uri;
905
+
quotedCid = record.embed.record.cid;
906
+
} else if (record.embed?.$type === 'app.bsky.embed.recordWithMedia') {
907
+
quotedUri = record.embed.record.record.uri;
908
+
quotedCid = record.embed.record.record.cid;
909
+
}
910
+
911
+
if (quotedUri) {
912
+
await this.storage.createQuote({
913
+
uri: `${uri}#quote`,
914
+
cid,
915
+
postUri: uri,
916
+
quotedUri,
917
+
quotedCid: quotedCid || undefined,
918
+
createdAt: this.safeDate(record.createdAt),
919
+
});
920
+
921
+
// Increment quoted post's quote count
922
+
await this.storage.incrementPostAggregation(quotedUri, 'quoteCount', 1);
923
+
924
+
// Create notification for quote
925
+
const quotedPost = await this.storage.getPost(quotedUri);
926
+
if (quotedPost && quotedPost.authorDid !== authorDid) {
927
+
await this.storage.createNotification({
928
+
uri: `at://${uri.replace('at://', '')}#notification/quote`,
929
+
recipientDid: quotedPost.authorDid,
930
+
authorDid,
931
+
reason: 'quote',
932
+
reasonSubject: uri,
933
+
cid: cid,
934
+
isRead: false,
935
+
createdAt: new Date(record.createdAt),
936
+
});
937
+
}
938
+
}
939
+
} catch (error) {
940
+
smartConsole.error(`[QUOTE] Error processing quote:`, error);
893
941
}
894
942
895
943
// Flush any pending operations for this post
···
1390
1438
1391
1439
await this.storage.createLabelerService(labelerService);
1392
1440
smartConsole.log(`[LABELER_SERVICE] Processed labeler service ${uri} for ${creatorDid}`);
1441
+
}
1442
+
1443
+
private async processVerification(uri: string, cid: string, creatorDid: string, record: any) {
1444
+
const creatorReady = await this.ensureUser(creatorDid);
1445
+
if (!creatorReady) {
1446
+
smartConsole.warn(`[EVENT_PROCESSOR] Skipping verification ${uri} - creator not ready`);
1447
+
return;
1448
+
}
1449
+
1450
+
// Check if data collection is forbidden for this user
1451
+
if (await this.isDataCollectionForbidden(creatorDid)) {
1452
+
return;
1453
+
}
1454
+
1455
+
const verification: InsertVerification = {
1456
+
uri,
1457
+
cid,
1458
+
subjectDid: record.subject || creatorDid,
1459
+
handle: record.handle || '',
1460
+
verifiedAt: this.safeDate(record.verifiedAt),
1461
+
createdAt: this.safeDate(record.createdAt),
1462
+
};
1463
+
1464
+
await this.storage.createVerification(verification);
1465
+
smartConsole.log(`[VERIFICATION] Processed verification ${uri} for ${verification.subjectDid}`);
1393
1466
}
1394
1467
1395
1468
// Guard against invalid or missing dates in upstream records
+251
server/services/hydration/README.md
+251
server/services/hydration/README.md
···
1
+
# Enhanced Hydration Service
2
+
3
+
A lightweight, production-ready hydration service for the AT Protocol AppView, implementing Bluesky's hydration patterns with Redis caching.
4
+
5
+
## Architecture
6
+
7
+
The hydration service consists of four main components:
8
+
9
+
### 1. ViewerContext Builder (`viewer-context.ts`)
10
+
Builds comprehensive viewer context including all relationships and preferences:
11
+
- Following/followers relationships
12
+
- Blocking/blocked relationships
13
+
- Muting relationships
14
+
- Thread mutes
15
+
- User preferences
16
+
- Actor viewer states (with URIs for follows, blocks)
17
+
- Post viewer states (likes, reposts, bookmarks)
18
+
19
+
### 2. Embed Resolver (`embed-resolver.ts`)
20
+
Resolves embeds recursively with circular reference protection:
21
+
- Maximum depth: 3 levels
22
+
- Supports: images, videos, external links, quote posts, record embeds
23
+
- Circular reference detection using visited URIs
24
+
- In-memory caching for performance
25
+
26
+
### 3. Label Propagator (`label-propagator.ts`)
27
+
Centralized moderation and label handling:
28
+
- Fetches labels for posts and actors
29
+
- Propagates actor labels to their content
30
+
- Takedown detection
31
+
- Content filtering based on labels and viewer preferences
32
+
- Handles: spam, NSFW, adult content, takedowns
33
+
34
+
### 4. Redis Caching Layer (`cache.ts`)
35
+
Lightweight caching wrapper around existing CacheService:
36
+
- 5-minute TTL by default
37
+
- Post, actor, and viewer context caching
38
+
- Batch operations (mget, mset)
39
+
- Cache invalidation
40
+
41
+
## Main Hydration Service (`index.ts`)
42
+
43
+
### Core Methods
44
+
45
+
#### `hydratePosts(postUris: string[], viewerDid?: string)`
46
+
Hydrates posts with full context:
47
+
- Post data (text, embed, reply structure)
48
+
- Author profiles
49
+
- Aggregations (like, repost, quote counts)
50
+
- Viewer states (liked, reposted, bookmarked)
51
+
- Actor viewer states (following, blocking, muting)
52
+
- Resolved embeds (recursive, 3 levels deep)
53
+
- Labels (propagated from actors)
54
+
55
+
**Returns:** `HydrationState` with all maps populated
56
+
57
+
#### `hydrateActors(actorDids: string[], viewerDid?: string)`
58
+
Hydrates actors with viewer relationships:
59
+
- Actor profiles
60
+
- Viewer states (following, blocked, muted)
61
+
- Labels
62
+
63
+
**Returns:** `HydrationState` with actor data
64
+
65
+
#### `hydratePostsCached(postUris: string[], viewerDid?: string)`
66
+
Cached version of `hydratePosts`:
67
+
- Checks Redis cache first
68
+
- Only fetches uncached posts
69
+
- Merges cached and fresh data
70
+
- Caches new data (5 min TTL)
71
+
72
+
## Usage Examples
73
+
74
+
### Basic Post Hydration
75
+
76
+
```typescript
77
+
import { enhancedHydrator } from './services/hydration';
78
+
79
+
// In an XRPC handler
80
+
async getTimeline(req: Request, res: Response) {
81
+
const userDid = await this.getAuthenticatedDid(req);
82
+
const postUris = [...]; // Get post URIs from feed
83
+
84
+
// Hydrate posts with viewer context
85
+
const state = await enhancedHydrator.hydratePosts(postUris, userDid);
86
+
87
+
// Build response using hydrated data
88
+
const posts = postUris.map(uri => {
89
+
const post = state.posts.get(uri);
90
+
const actor = state.actors.get(post.authorDid);
91
+
const agg = state.aggregations.get(uri);
92
+
const viewerState = state.viewerStates.get(uri);
93
+
const actorViewerState = state.actorViewerStates.get(post.authorDid);
94
+
const embed = state.embeds.get(uri);
95
+
const labels = state.labels.get(uri) || [];
96
+
97
+
return {
98
+
uri: post.uri,
99
+
cid: post.cid,
100
+
author: {
101
+
did: actor.did,
102
+
handle: actor.handle,
103
+
displayName: actor.displayName,
104
+
viewer: actorViewerState
105
+
},
106
+
record: {
107
+
text: post.text,
108
+
embed: post.embed
109
+
},
110
+
embed: embed, // Resolved embed
111
+
likeCount: agg?.likeCount || 0,
112
+
repostCount: agg?.repostCount || 0,
113
+
quoteCount: agg?.quoteCount || 0,
114
+
viewer: {
115
+
like: viewerState?.likeUri,
116
+
repost: viewerState?.repostUri,
117
+
bookmarked: viewerState?.bookmarked
118
+
},
119
+
labels: labels
120
+
};
121
+
});
122
+
123
+
res.json({ feed: posts });
124
+
}
125
+
```
126
+
127
+
### Cached Post Hydration
128
+
129
+
```typescript
130
+
// Currently performs full hydration to ensure complete state
131
+
// Future optimization: implement full HydrationState caching
132
+
const state = await enhancedHydrator.hydratePostsCached(postUris, userDid);
133
+
```
134
+
135
+
**Note**: The cached version currently performs full hydration to ensure all maps (actors, aggregations, viewer states, etc.) are properly populated. Future optimization will cache the complete HydrationState.
136
+
137
+
### Actor Hydration
138
+
139
+
```typescript
140
+
async getProfiles(req: Request, res: Response) {
141
+
const viewerDid = await this.getAuthenticatedDid(req);
142
+
const actorDids = req.query.actors as string[];
143
+
144
+
const state = await enhancedHydrator.hydrateActors(actorDids, viewerDid);
145
+
146
+
const profiles = actorDids.map(did => {
147
+
const actor = state.actors.get(did);
148
+
const viewerState = state.actorViewerStates.get(did);
149
+
const labels = state.labels.get(did) || [];
150
+
151
+
return {
152
+
did: actor.did,
153
+
handle: actor.handle,
154
+
displayName: actor.displayName,
155
+
description: actor.description,
156
+
avatar: actor.avatarUrl,
157
+
viewer: viewerState,
158
+
labels: labels
159
+
};
160
+
});
161
+
162
+
res.json({ profiles });
163
+
}
164
+
```
165
+
166
+
### Content Filtering
167
+
168
+
```typescript
169
+
import { LabelPropagator } from './services/hydration';
170
+
171
+
const labelPropagator = new LabelPropagator();
172
+
173
+
// Filter content based on labels
174
+
const allowedUris = await labelPropagator.filterContent(
175
+
postUris,
176
+
viewerContext?.preferences
177
+
);
178
+
179
+
// Only return allowed content
180
+
const filteredPosts = posts.filter(p => allowedUris.has(p.uri));
181
+
```
182
+
183
+
## HydrationState Interface
184
+
185
+
```typescript
186
+
interface HydrationState {
187
+
posts: Map<string, any>; // Post data by URI
188
+
actors: Map<string, any>; // Actor data by DID
189
+
aggregations: Map<string, any>; // Post aggregations by URI
190
+
viewerStates: Map<string, any>; // Post viewer states by URI
191
+
actorViewerStates: Map<string, any>; // Actor viewer states by DID
192
+
embeds: Map<string, any>; // Resolved embeds by URI
193
+
labels: Map<string, Label[]>; // Labels by subject URI/DID
194
+
viewerContext?: ViewerContext; // Full viewer context
195
+
}
196
+
```
197
+
198
+
## Performance Characteristics
199
+
200
+
- **Batched Queries**: Single query per data type (posts, actors, aggregations)
201
+
- **Parallel Fetching**: All data types fetched concurrently
202
+
- **Redis Caching**: 5-minute TTL reduces database load
203
+
- **Embed Caching**: In-memory cache for embed resolution
204
+
- **Circular Protection**: Prevents infinite loops in embed resolution
205
+
- **Label Propagation**: Efficient actor-to-content label inheritance
206
+
207
+
## Migration Guide
208
+
209
+
### From Old Hydrator
210
+
211
+
```typescript
212
+
// Old way (basic)
213
+
const hydrator = new Hydrator();
214
+
const state = await hydrator.hydrateFeedItems(items, viewerDid);
215
+
216
+
// New way (enhanced)
217
+
import { enhancedHydrator } from './services/hydration';
218
+
const postUris = items.map(i => i.post.uri);
219
+
const state = await enhancedHydrator.hydratePosts(postUris, viewerDid);
220
+
```
221
+
222
+
### Benefits of Enhanced Hydrator
223
+
224
+
1. **Richer Viewer Context**: Full relationship data, not just blocks/mutes
225
+
2. **Recursive Embeds**: 3-level deep embed resolution
226
+
3. **Label Propagation**: Centralized moderation logic
227
+
4. **Redis Caching**: Significant performance improvement
228
+
5. **Actor Hydration**: Dedicated method for profile hydration
229
+
6. **Standardized**: Follows Bluesky's hydration patterns
230
+
231
+
## Implementation Status
232
+
233
+
✅ **Completed:**
234
+
- ViewerContext builder with relationships
235
+
- Embed resolver with circular protection
236
+
- Label propagator with takedown logic
237
+
- Main hydration service
238
+
- Actor hydration
239
+
- Post hydration
240
+
241
+
⚠️ **Limitations:**
242
+
- **Caching Disabled**: The `hydratePostsCached` method currently performs full hydration on every call. While a Redis caching layer exists, proper HydrationState snapshot caching is not yet implemented to avoid incomplete state bugs.
243
+
- **Embed Resolution**: Basic embed structure is resolved, but full record views for quotes/embeds matching the Bluesky spec require additional work.
244
+
245
+
📋 **Next Steps (Optional):**
246
+
- Implement proper HydrationState caching (cache complete state snapshots)
247
+
- Enhance embed resolver to build full record views for quotes
248
+
- Integrate into all XRPC handlers
249
+
- Add metrics/monitoring
250
+
- Performance benchmarking
251
+
- Cache invalidation on mutations
+93
server/services/hydration/cache.ts
+93
server/services/hydration/cache.ts
···
1
+
import { CacheService } from '../cache';
2
+
3
+
export class HydrationCache {
4
+
private readonly TTL = 300; // 5 minutes
5
+
private cache: CacheService;
6
+
7
+
constructor() {
8
+
this.cache = new CacheService({ ttl: this.TTL, keyPrefix: 'hydration:' });
9
+
}
10
+
11
+
/**
12
+
* Get cached hydration data
13
+
*/
14
+
async get<T>(key: string): Promise<T | null> {
15
+
return await this.cache.get<T>(key);
16
+
}
17
+
18
+
/**
19
+
* Set cached hydration data
20
+
*/
21
+
async set(key: string, value: any, ttl: number = this.TTL): Promise<void> {
22
+
await this.cache.set(key, value, ttl);
23
+
}
24
+
25
+
/**
26
+
* Get multiple cached values (not supported by current cache, fetch individually)
27
+
*/
28
+
async mget<T>(keys: string[]): Promise<Map<string, T>> {
29
+
const result = new Map<string, T>();
30
+
31
+
for (const key of keys) {
32
+
const value = await this.get<T>(key);
33
+
if (value) {
34
+
result.set(key, value);
35
+
}
36
+
}
37
+
38
+
return result;
39
+
}
40
+
41
+
/**
42
+
* Set multiple cached values
43
+
*/
44
+
async mset(entries: Map<string, any>, ttl: number = this.TTL): Promise<void> {
45
+
for (const [key, value] of Array.from(entries.entries())) {
46
+
await this.set(key, value, ttl);
47
+
}
48
+
}
49
+
50
+
/**
51
+
* Invalidate cached data
52
+
*/
53
+
async invalidate(key: string): Promise<void> {
54
+
await this.cache.del(key);
55
+
}
56
+
57
+
/**
58
+
* Invalidate multiple keys
59
+
*/
60
+
async invalidateMany(keys: string[]): Promise<void> {
61
+
for (const key of keys) {
62
+
await this.invalidate(key);
63
+
}
64
+
}
65
+
66
+
/**
67
+
* Build cache key for posts
68
+
*/
69
+
postKey(uri: string): string {
70
+
return `post:${uri}`;
71
+
}
72
+
73
+
/**
74
+
* Build cache key for actor
75
+
*/
76
+
actorKey(did: string): string {
77
+
return `actor:${did}`;
78
+
}
79
+
80
+
/**
81
+
* Build cache key for viewer context
82
+
*/
83
+
viewerContextKey(did: string): string {
84
+
return `viewer:${did}`;
85
+
}
86
+
87
+
/**
88
+
* Build cache key for labels
89
+
*/
90
+
labelsKey(uri: string): string {
91
+
return `labels:${uri}`;
92
+
}
93
+
}
+188
server/services/hydration/embed-resolver.ts
+188
server/services/hydration/embed-resolver.ts
···
1
+
import { db } from '../../db';
2
+
import { posts, users } from '../../../shared/schema';
3
+
import { eq, inArray } from 'drizzle-orm';
4
+
5
+
export interface ResolvedEmbed {
6
+
$type: string;
7
+
[key: string]: any;
8
+
}
9
+
10
+
export class EmbedResolver {
11
+
private readonly MAX_DEPTH = 3;
12
+
private cache = new Map<string, ResolvedEmbed | null>();
13
+
14
+
/**
15
+
* Resolve embeds recursively up to MAX_DEPTH levels
16
+
* Handles circular reference detection
17
+
*/
18
+
async resolveEmbeds(
19
+
postUris: string[],
20
+
depth = 0,
21
+
visited = new Set<string>()
22
+
): Promise<Map<string, ResolvedEmbed | null>> {
23
+
if (depth >= this.MAX_DEPTH || postUris.length === 0) {
24
+
return new Map();
25
+
}
26
+
27
+
// Filter out already visited URIs (circular reference protection)
28
+
const newUris = postUris.filter(uri => !visited.has(uri));
29
+
if (newUris.length === 0) {
30
+
return new Map();
31
+
}
32
+
33
+
// Mark all URIs as visited
34
+
newUris.forEach(uri => visited.add(uri));
35
+
36
+
// Check cache first
37
+
const uncachedUris = newUris.filter(uri => !this.cache.has(uri));
38
+
39
+
if (uncachedUris.length === 0) {
40
+
const result = new Map();
41
+
newUris.forEach(uri => {
42
+
result.set(uri, this.cache.get(uri) || null);
43
+
});
44
+
return result;
45
+
}
46
+
47
+
// Fetch posts with embeds
48
+
const postsData = await db
49
+
.select()
50
+
.from(posts)
51
+
.where(inArray(posts.uri, uncachedUris));
52
+
53
+
const result = new Map<string, ResolvedEmbed | null>();
54
+
const childUris: string[] = [];
55
+
56
+
for (const post of postsData) {
57
+
if (!post.embed) {
58
+
result.set(post.uri, null);
59
+
this.cache.set(post.uri, null);
60
+
continue;
61
+
}
62
+
63
+
const embed = post.embed as any;
64
+
let resolved: ResolvedEmbed | null = null;
65
+
66
+
// Handle different embed types
67
+
if (embed.$type === 'app.bsky.embed.record') {
68
+
// Quote post or record embed
69
+
const recordUri = embed.record?.uri;
70
+
if (recordUri) {
71
+
childUris.push(recordUri);
72
+
resolved = {
73
+
$type: 'app.bsky.embed.record#view',
74
+
record: { uri: recordUri } // Will be hydrated recursively
75
+
};
76
+
}
77
+
} else if (embed.$type === 'app.bsky.embed.recordWithMedia') {
78
+
// Quote with media
79
+
const recordUri = embed.record?.record?.uri;
80
+
if (recordUri) {
81
+
childUris.push(recordUri);
82
+
}
83
+
resolved = {
84
+
$type: 'app.bsky.embed.recordWithMedia#view',
85
+
record: recordUri ? { uri: recordUri } : undefined,
86
+
media: this.resolveMediaEmbed(embed.media)
87
+
};
88
+
} else if (embed.$type === 'app.bsky.embed.images') {
89
+
// Images
90
+
resolved = this.resolveImagesEmbed(embed);
91
+
} else if (embed.$type === 'app.bsky.embed.external') {
92
+
// External link
93
+
resolved = this.resolveExternalEmbed(embed);
94
+
} else if (embed.$type === 'app.bsky.embed.video') {
95
+
// Video
96
+
resolved = this.resolveVideoEmbed(embed);
97
+
}
98
+
99
+
result.set(post.uri, resolved);
100
+
this.cache.set(post.uri, resolved);
101
+
}
102
+
103
+
// Recursively resolve child embeds
104
+
if (childUris.length > 0 && depth < this.MAX_DEPTH - 1) {
105
+
const childEmbeds = await this.resolveEmbeds(childUris, depth + 1, visited);
106
+
107
+
// Update parent embeds with resolved children
108
+
for (const [uri, embed] of Array.from(result.entries())) {
109
+
if (embed && embed.$type === 'app.bsky.embed.record#view') {
110
+
const recordUri = embed.record?.uri;
111
+
if (recordUri && childEmbeds.has(recordUri)) {
112
+
embed.record = childEmbeds.get(recordUri);
113
+
}
114
+
} else if (embed && embed.$type === 'app.bsky.embed.recordWithMedia#view') {
115
+
const recordUri = embed.record?.uri;
116
+
if (recordUri && childEmbeds.has(recordUri)) {
117
+
embed.record = childEmbeds.get(recordUri);
118
+
}
119
+
}
120
+
}
121
+
}
122
+
123
+
return result;
124
+
}
125
+
126
+
private resolveImagesEmbed(embed: any): ResolvedEmbed {
127
+
return {
128
+
$type: 'app.bsky.embed.images#view',
129
+
images: (embed.images || []).map((img: any) => ({
130
+
thumb: this.blobToCdnUrl(img.image),
131
+
fullsize: this.blobToCdnUrl(img.image),
132
+
alt: img.alt || '',
133
+
aspectRatio: img.aspectRatio
134
+
}))
135
+
};
136
+
}
137
+
138
+
private resolveExternalEmbed(embed: any): ResolvedEmbed {
139
+
return {
140
+
$type: 'app.bsky.embed.external#view',
141
+
external: {
142
+
uri: embed.external?.uri || '',
143
+
title: embed.external?.title || '',
144
+
description: embed.external?.description || '',
145
+
thumb: embed.external?.thumb ? this.blobToCdnUrl(embed.external.thumb) : undefined
146
+
}
147
+
};
148
+
}
149
+
150
+
private resolveVideoEmbed(embed: any): ResolvedEmbed {
151
+
return {
152
+
$type: 'app.bsky.embed.video#view',
153
+
cid: embed.video?.ref?.$link || '',
154
+
playlist: '', // TODO: generate video playlist URL
155
+
thumbnail: embed.thumbnail ? this.blobToCdnUrl(embed.thumbnail) : undefined,
156
+
alt: embed.alt || '',
157
+
aspectRatio: embed.aspectRatio
158
+
};
159
+
}
160
+
161
+
private resolveMediaEmbed(media: any): any {
162
+
if (!media) return undefined;
163
+
164
+
if (media.$type === 'app.bsky.embed.images') {
165
+
return this.resolveImagesEmbed(media);
166
+
} else if (media.$type === 'app.bsky.embed.external') {
167
+
return this.resolveExternalEmbed(media);
168
+
} else if (media.$type === 'app.bsky.embed.video') {
169
+
return this.resolveVideoEmbed(media);
170
+
}
171
+
172
+
return undefined;
173
+
}
174
+
175
+
private blobToCdnUrl(blob: any): string {
176
+
if (!blob || !blob.ref) return '';
177
+
const cid = typeof blob.ref === 'string' ? blob.ref : blob.ref.$link;
178
+
// TODO: Use actual CDN URL from environment
179
+
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${cid}@jpeg`;
180
+
}
181
+
182
+
/**
183
+
* Clear the embed cache
184
+
*/
185
+
clearCache() {
186
+
this.cache.clear();
187
+
}
188
+
}
+250
server/services/hydration/index.ts
+250
server/services/hydration/index.ts
···
1
+
import { db } from '../../db';
2
+
import {
3
+
posts,
4
+
users,
5
+
postAggregations,
6
+
postViewerStates
7
+
} from '../../../shared/schema';
8
+
import { eq, inArray, sql } from 'drizzle-orm';
9
+
import { ViewerContextBuilder, ViewerContext } from './viewer-context';
10
+
import { EmbedResolver } from './embed-resolver';
11
+
import { LabelPropagator, Label } from './label-propagator';
12
+
import { HydrationCache } from './cache';
13
+
14
+
export interface HydrationState {
15
+
posts: Map<string, any>;
16
+
actors: Map<string, any>;
17
+
aggregations: Map<string, any>;
18
+
viewerStates: Map<string, any>;
19
+
actorViewerStates: Map<string, any>;
20
+
embeds: Map<string, any>;
21
+
labels: Map<string, Label[]>;
22
+
viewerContext?: ViewerContext;
23
+
}
24
+
25
+
export class EnhancedHydrator {
26
+
private viewerBuilder = new ViewerContextBuilder();
27
+
private embedResolver = new EmbedResolver();
28
+
private labelPropagator = new LabelPropagator();
29
+
private cache = new HydrationCache();
30
+
31
+
/**
32
+
* Hydrate posts with full context including viewer states, embeds, and labels
33
+
*/
34
+
async hydratePosts(
35
+
postUris: string[],
36
+
viewerDid?: string
37
+
): Promise<HydrationState> {
38
+
if (postUris.length === 0) {
39
+
return this.emptyState();
40
+
}
41
+
42
+
// Build viewer context if authenticated
43
+
const viewerContext = viewerDid
44
+
? await this.viewerBuilder.build(viewerDid)
45
+
: undefined;
46
+
47
+
// Fetch posts
48
+
const postsData = await db
49
+
.select()
50
+
.from(posts)
51
+
.where(inArray(posts.uri, postUris));
52
+
53
+
const postsMap = new Map<string, any>();
54
+
const actorDids = new Set<string>();
55
+
56
+
for (const post of postsData) {
57
+
postsMap.set(post.uri, {
58
+
uri: post.uri,
59
+
cid: post.cid,
60
+
authorDid: post.authorDid,
61
+
text: post.text,
62
+
createdAt: post.createdAt.toISOString(),
63
+
indexedAt: post.indexedAt.toISOString(),
64
+
embed: post.embed,
65
+
reply: post.parentUri ? {
66
+
parent: { uri: post.parentUri },
67
+
root: { uri: post.rootUri || post.parentUri }
68
+
} : undefined,
69
+
tags: post.tags
70
+
});
71
+
actorDids.add(post.authorDid);
72
+
}
73
+
74
+
// Fetch aggregations
75
+
const aggregationsData = await db
76
+
.select()
77
+
.from(postAggregations)
78
+
.where(inArray(postAggregations.postUri, postUris));
79
+
80
+
const aggregationsMap = new Map<string, any>();
81
+
for (const agg of aggregationsData) {
82
+
aggregationsMap.set(agg.postUri, {
83
+
likeCount: agg.likeCount,
84
+
repostCount: agg.repostCount,
85
+
replyCount: agg.replyCount,
86
+
quoteCount: agg.quoteCount,
87
+
bookmarkCount: agg.bookmarkCount
88
+
});
89
+
}
90
+
91
+
// Fetch viewer states for posts
92
+
let viewerStatesMap = new Map<string, any>();
93
+
if (viewerDid) {
94
+
viewerStatesMap = await this.viewerBuilder.buildPostStates(viewerDid, postUris);
95
+
}
96
+
97
+
// Fetch actors
98
+
const actorsData = await db
99
+
.select()
100
+
.from(users)
101
+
.where(inArray(users.did, Array.from(actorDids)));
102
+
103
+
const actorsMap = new Map<string, any>();
104
+
for (const actor of actorsData) {
105
+
actorsMap.set(actor.did, {
106
+
did: actor.did,
107
+
handle: actor.handle,
108
+
displayName: actor.displayName,
109
+
description: actor.description,
110
+
avatarUrl: actor.avatarUrl,
111
+
indexedAt: actor.indexedAt?.toISOString()
112
+
});
113
+
}
114
+
115
+
// Fetch viewer states for actors
116
+
let actorViewerStatesMap = new Map<string, any>();
117
+
if (viewerDid) {
118
+
actorViewerStatesMap = await this.viewerBuilder.buildActorStates(
119
+
viewerDid,
120
+
Array.from(actorDids)
121
+
);
122
+
}
123
+
124
+
// Resolve embeds
125
+
const embedsMap = await this.embedResolver.resolveEmbeds(postUris);
126
+
127
+
// Fetch labels
128
+
const allSubjects = [...postUris, ...Array.from(actorDids)];
129
+
const labelsMap = await this.labelPropagator.propagateActorLabels(
130
+
Array.from(actorDids),
131
+
postUris
132
+
);
133
+
134
+
return {
135
+
posts: postsMap,
136
+
actors: actorsMap,
137
+
aggregations: aggregationsMap,
138
+
viewerStates: viewerStatesMap,
139
+
actorViewerStates: actorViewerStatesMap,
140
+
embeds: embedsMap,
141
+
labels: labelsMap,
142
+
viewerContext
143
+
};
144
+
}
145
+
146
+
/**
147
+
* Hydrate actors with viewer states
148
+
*/
149
+
async hydrateActors(
150
+
actorDids: string[],
151
+
viewerDid?: string
152
+
): Promise<HydrationState> {
153
+
if (actorDids.length === 0) {
154
+
return this.emptyState();
155
+
}
156
+
157
+
const viewerContext = viewerDid
158
+
? await this.viewerBuilder.build(viewerDid)
159
+
: undefined;
160
+
161
+
// Fetch actors
162
+
const actorsData = await db
163
+
.select()
164
+
.from(users)
165
+
.where(inArray(users.did, actorDids));
166
+
167
+
const actorsMap = new Map<string, any>();
168
+
for (const actor of actorsData) {
169
+
actorsMap.set(actor.did, {
170
+
did: actor.did,
171
+
handle: actor.handle,
172
+
displayName: actor.displayName,
173
+
description: actor.description,
174
+
avatarUrl: actor.avatarUrl,
175
+
bannerUrl: actor.bannerUrl,
176
+
indexedAt: actor.indexedAt?.toISOString()
177
+
});
178
+
}
179
+
180
+
// Fetch viewer states for actors
181
+
let actorViewerStatesMap = new Map<string, any>();
182
+
if (viewerDid) {
183
+
actorViewerStatesMap = await this.viewerBuilder.buildActorStates(
184
+
viewerDid,
185
+
actorDids
186
+
);
187
+
}
188
+
189
+
// Fetch labels
190
+
const labelsMap = await this.labelPropagator.getLabels(actorDids);
191
+
192
+
return {
193
+
posts: new Map(),
194
+
actors: actorsMap,
195
+
aggregations: new Map(),
196
+
viewerStates: new Map(),
197
+
actorViewerStates: actorViewerStatesMap,
198
+
embeds: new Map(),
199
+
labels: labelsMap,
200
+
viewerContext
201
+
};
202
+
}
203
+
204
+
/**
205
+
* Hydrate with caching
206
+
* Note: Simplified implementation - always performs full hydration for complete state
207
+
* TODO: Implement proper state caching that includes all hydrated data (actors, aggregations, etc.)
208
+
*/
209
+
async hydratePostsCached(
210
+
postUris: string[],
211
+
viewerDid?: string
212
+
): Promise<HydrationState> {
213
+
// For now, always do full hydration to ensure complete state with all maps populated
214
+
// Future optimization: Cache full HydrationState snapshots including actors, aggregations, etc.
215
+
return await this.hydratePosts(postUris, viewerDid);
216
+
}
217
+
218
+
/**
219
+
* Clear hydration cache
220
+
*/
221
+
async clearCache() {
222
+
this.embedResolver.clearCache();
223
+
}
224
+
225
+
private emptyState(): HydrationState {
226
+
return {
227
+
posts: new Map(),
228
+
actors: new Map(),
229
+
aggregations: new Map(),
230
+
viewerStates: new Map(),
231
+
actorViewerStates: new Map(),
232
+
embeds: new Map(),
233
+
labels: new Map()
234
+
};
235
+
}
236
+
}
237
+
238
+
// Export singleton instance
239
+
export const enhancedHydrator = new EnhancedHydrator();
240
+
241
+
// Re-export classes
242
+
export { ViewerContextBuilder } from './viewer-context';
243
+
export { EmbedResolver } from './embed-resolver';
244
+
export { LabelPropagator } from './label-propagator';
245
+
export { HydrationCache } from './cache';
246
+
247
+
// Re-export types
248
+
export type { ViewerContext } from './viewer-context';
249
+
export type { ResolvedEmbed } from './embed-resolver';
250
+
export type { Label } from './label-propagator';
+200
server/services/hydration/label-propagator.ts
+200
server/services/hydration/label-propagator.ts
···
1
+
import { db } from '../../db';
2
+
import { labels, users } from '../../../shared/schema';
3
+
import { inArray, eq, or, and } from 'drizzle-orm';
4
+
5
+
export interface Label {
6
+
$type: 'com.atproto.label.defs#label';
7
+
src: string;
8
+
uri: string;
9
+
cid?: string;
10
+
val: string;
11
+
neg?: boolean;
12
+
cts: string;
13
+
exp?: string;
14
+
}
15
+
16
+
export interface Takedown {
17
+
isTakendown: boolean;
18
+
reason?: string;
19
+
}
20
+
21
+
export class LabelPropagator {
22
+
/**
23
+
* Fetch labels for a set of subjects (posts, actors, etc.)
24
+
*/
25
+
async getLabels(subjects: string[]): Promise<Map<string, Label[]>> {
26
+
if (subjects.length === 0) return new Map();
27
+
28
+
const labelsData = await db
29
+
.select()
30
+
.from(labels)
31
+
.where(inArray(labels.subject, subjects));
32
+
33
+
const result = new Map<string, Label[]>();
34
+
35
+
for (const label of labelsData) {
36
+
const existing = result.get(label.subject) || [];
37
+
existing.push({
38
+
$type: 'com.atproto.label.defs#label',
39
+
src: label.src,
40
+
uri: label.subject,
41
+
cid: undefined, // CID not stored in current schema
42
+
val: label.val,
43
+
neg: label.neg || undefined,
44
+
cts: label.createdAt.toISOString(),
45
+
exp: undefined // Expiration not in current schema
46
+
});
47
+
result.set(label.subject, existing);
48
+
}
49
+
50
+
return result;
51
+
}
52
+
53
+
/**
54
+
* Check if content is taken down based on labels
55
+
*/
56
+
checkTakedown(labels: Label[]): Takedown {
57
+
if (!labels || labels.length === 0) {
58
+
return { isTakendown: false };
59
+
}
60
+
61
+
// Check for takedown labels
62
+
const takedownLabel = labels.find(l =>
63
+
l.val === '!takedown' ||
64
+
l.val === '!suspend' ||
65
+
l.val === 'dmca-violation'
66
+
);
67
+
68
+
if (takedownLabel) {
69
+
return {
70
+
isTakendown: true,
71
+
reason: takedownLabel.val
72
+
};
73
+
}
74
+
75
+
return { isTakendown: false };
76
+
}
77
+
78
+
/**
79
+
* Filter content based on moderation labels and viewer preferences
80
+
*/
81
+
shouldFilter(
82
+
labels: Label[],
83
+
viewerPreferences?: any
84
+
): { shouldHide: boolean; reason?: string } {
85
+
if (!labels || labels.length === 0) {
86
+
return { shouldHide: false };
87
+
}
88
+
89
+
// Always filter takedowns
90
+
const takedown = this.checkTakedown(labels);
91
+
if (takedown.isTakendown) {
92
+
return { shouldHide: true, reason: 'takedown' };
93
+
}
94
+
95
+
// Check for NSFW/adult content labels
96
+
const nsfwLabels = labels.filter(l =>
97
+
l.val === 'porn' ||
98
+
l.val === 'sexual' ||
99
+
l.val === 'nudity' ||
100
+
l.val === 'graphic-media'
101
+
);
102
+
103
+
if (nsfwLabels.length > 0) {
104
+
// Check viewer preferences
105
+
const hideNsfw = viewerPreferences?.adultContentEnabled === false;
106
+
if (hideNsfw) {
107
+
return { shouldHide: true, reason: 'nsfw' };
108
+
}
109
+
}
110
+
111
+
// Check for spam
112
+
const spamLabels = labels.filter(l => l.val === 'spam');
113
+
if (spamLabels.length > 0) {
114
+
return { shouldHide: true, reason: 'spam' };
115
+
}
116
+
117
+
return { shouldHide: false };
118
+
}
119
+
120
+
/**
121
+
* Propagate labels from actors to their content
122
+
* If an actor is labeled, their content inherits those labels
123
+
*/
124
+
async propagateActorLabels(
125
+
actorDids: string[],
126
+
contentUris: string[]
127
+
): Promise<Map<string, Label[]>> {
128
+
if (actorDids.length === 0 || contentUris.length === 0) {
129
+
return new Map();
130
+
}
131
+
132
+
// Get actor labels
133
+
const actorLabels = await this.getLabels(actorDids);
134
+
135
+
// Get content labels
136
+
const contentLabels = await this.getLabels(contentUris);
137
+
138
+
// Build content URI to actor DID mapping
139
+
const contentToActor = new Map<string, string>();
140
+
141
+
// Extract actor DID from content URI (at://did/collection/rkey)
142
+
for (const uri of contentUris) {
143
+
const match = uri.match(/^at:\/\/([^/]+)\//);
144
+
if (match) {
145
+
contentToActor.set(uri, match[1]);
146
+
}
147
+
}
148
+
149
+
// Propagate actor labels to content
150
+
const result = new Map<string, Label[]>();
151
+
152
+
for (const uri of contentUris) {
153
+
const actorDid = contentToActor.get(uri);
154
+
const contentLabelsList = contentLabels.get(uri) || [];
155
+
const actorLabelsList = actorDid ? (actorLabels.get(actorDid) || []) : [];
156
+
157
+
// Combine content and actor labels (content labels take precedence)
158
+
const combined = [...contentLabelsList, ...actorLabelsList];
159
+
160
+
// Remove duplicates
161
+
const unique = Array.from(
162
+
new Map(combined.map(l => [`${l.src}:${l.val}`, l])).values()
163
+
);
164
+
165
+
result.set(uri, unique);
166
+
}
167
+
168
+
return result;
169
+
}
170
+
171
+
/**
172
+
* Filter a list of content URIs based on moderation rules
173
+
* Returns only URIs that should be shown
174
+
*/
175
+
async filterContent(
176
+
contentUris: string[],
177
+
viewerPreferences?: any
178
+
): Promise<Set<string>> {
179
+
const actorDids = contentUris
180
+
.map(uri => {
181
+
const match = uri.match(/^at:\/\/([^/]+)\//);
182
+
return match ? match[1] : null;
183
+
})
184
+
.filter(Boolean) as string[];
185
+
186
+
const allLabels = await this.propagateActorLabels(actorDids, contentUris);
187
+
const allowed = new Set<string>();
188
+
189
+
for (const uri of contentUris) {
190
+
const labels = allLabels.get(uri) || [];
191
+
const filter = this.shouldFilter(labels, viewerPreferences);
192
+
193
+
if (!filter.shouldHide) {
194
+
allowed.add(uri);
195
+
}
196
+
}
197
+
198
+
return allowed;
199
+
}
200
+
}
+244
server/services/hydration/viewer-context.ts
+244
server/services/hydration/viewer-context.ts
···
1
+
import { db } from '../../db';
2
+
import {
3
+
users,
4
+
blocks,
5
+
mutes,
6
+
follows,
7
+
userPreferences,
8
+
listMutes,
9
+
threadMutes
10
+
} from '../../../shared/schema';
11
+
import { eq, and, inArray, sql } from 'drizzle-orm';
12
+
13
+
export interface ViewerContext {
14
+
did: string;
15
+
following: Set<string>;
16
+
followers: Set<string>;
17
+
blocking: Set<string>;
18
+
blockedBy: Set<string>;
19
+
muting: Set<string>;
20
+
mutedByLists: Map<string, string>; // did -> list URI
21
+
threadMutes: Set<string>; // thread URIs
22
+
preferences?: any;
23
+
}
24
+
25
+
export class ViewerContextBuilder {
26
+
/**
27
+
* Build comprehensive viewer context for a given DID
28
+
* This includes all relationships and preferences needed for view construction
29
+
*/
30
+
async build(viewerDid: string): Promise<ViewerContext> {
31
+
const [
32
+
followingData,
33
+
followersData,
34
+
blockingData,
35
+
blockedByData,
36
+
mutingData,
37
+
threadMutesData,
38
+
preferencesData
39
+
] = await Promise.all([
40
+
// Following
41
+
db.select({ followingDid: follows.followingDid })
42
+
.from(follows)
43
+
.where(eq(follows.followerDid, viewerDid)),
44
+
45
+
// Followers
46
+
db.select({ followerDid: follows.followerDid })
47
+
.from(follows)
48
+
.where(eq(follows.followingDid, viewerDid)),
49
+
50
+
// Blocking
51
+
db.select({ blockedDid: blocks.blockedDid })
52
+
.from(blocks)
53
+
.where(eq(blocks.blockerDid, viewerDid)),
54
+
55
+
// Blocked by
56
+
db.select({ blockerDid: blocks.blockerDid })
57
+
.from(blocks)
58
+
.where(eq(blocks.blockedDid, viewerDid)),
59
+
60
+
// Muting
61
+
db.select({ mutedDid: mutes.mutedDid })
62
+
.from(mutes)
63
+
.where(eq(mutes.muterDid, viewerDid)),
64
+
65
+
// Thread mutes
66
+
db.select({ threadRootUri: threadMutes.threadRootUri })
67
+
.from(threadMutes)
68
+
.where(eq(threadMutes.muterDid, viewerDid)),
69
+
70
+
// Preferences
71
+
db.select()
72
+
.from(userPreferences)
73
+
.where(eq(userPreferences.userDid, viewerDid))
74
+
.limit(1)
75
+
]);
76
+
77
+
return {
78
+
did: viewerDid,
79
+
following: new Set(followingData.map(f => f.followingDid)),
80
+
followers: new Set(followersData.map(f => f.followerDid)),
81
+
blocking: new Set(blockingData.map(b => b.blockedDid)),
82
+
blockedBy: new Set(blockedByData.map(b => b.blockerDid)),
83
+
muting: new Set(mutingData.map(m => m.mutedDid)),
84
+
mutedByLists: new Map(), // TODO: implement list-based muting
85
+
threadMutes: new Set(threadMutesData.map(t => t.threadRootUri)),
86
+
preferences: preferencesData[0]
87
+
};
88
+
}
89
+
90
+
/**
91
+
* Build viewer states for a set of actors
92
+
* Returns a map of DID -> viewer relationship state
93
+
*/
94
+
async buildActorStates(
95
+
viewerDid: string,
96
+
actorDids: string[]
97
+
): Promise<Map<string, any>> {
98
+
if (actorDids.length === 0) return new Map();
99
+
100
+
const ctx = await this.build(viewerDid);
101
+
const result = new Map();
102
+
103
+
// Get follow URIs for actors
104
+
const followingUris = await db
105
+
.select({
106
+
followingDid: follows.followingDid,
107
+
uri: follows.uri
108
+
})
109
+
.from(follows)
110
+
.where(
111
+
and(
112
+
eq(follows.followerDid, viewerDid),
113
+
inArray(follows.followingDid, actorDids)
114
+
)
115
+
);
116
+
117
+
const followedByUris = await db
118
+
.select({
119
+
followerDid: follows.followerDid,
120
+
uri: follows.uri
121
+
})
122
+
.from(follows)
123
+
.where(
124
+
and(
125
+
eq(follows.followingDid, viewerDid),
126
+
inArray(follows.followerDid, actorDids)
127
+
)
128
+
);
129
+
130
+
const blockingUris = await db
131
+
.select({
132
+
blockedDid: blocks.blockedDid,
133
+
uri: blocks.uri
134
+
})
135
+
.from(blocks)
136
+
.where(
137
+
and(
138
+
eq(blocks.blockerDid, viewerDid),
139
+
inArray(blocks.blockedDid, actorDids)
140
+
)
141
+
);
142
+
143
+
for (const did of actorDids) {
144
+
const state: any = {};
145
+
146
+
// Following
147
+
const followingUri = followingUris.find(f => f.followingDid === did)?.uri;
148
+
if (followingUri) {
149
+
state.following = followingUri;
150
+
}
151
+
152
+
// Followed by
153
+
const followedByUri = followedByUris.find(f => f.followerDid === did)?.uri;
154
+
if (followedByUri) {
155
+
state.followedBy = followedByUri;
156
+
}
157
+
158
+
// Blocking
159
+
const blockingUri = blockingUris.find(b => b.blockedDid === did)?.uri;
160
+
if (blockingUri) {
161
+
state.blocking = blockingUri;
162
+
}
163
+
164
+
// Blocked by
165
+
if (ctx.blockedBy.has(did)) {
166
+
state.blockedBy = true;
167
+
}
168
+
169
+
// Muting
170
+
if (ctx.muting.has(did)) {
171
+
state.muted = true;
172
+
}
173
+
174
+
result.set(did, state);
175
+
}
176
+
177
+
return result;
178
+
}
179
+
180
+
/**
181
+
* Build viewer states for a set of posts
182
+
* Returns a map of post URI -> viewer state
183
+
*/
184
+
async buildPostStates(
185
+
viewerDid: string,
186
+
postUris: string[]
187
+
): Promise<Map<string, any>> {
188
+
if (postUris.length === 0) return new Map();
189
+
190
+
const [likesData, repostsData, bookmarksData] = await Promise.all([
191
+
db.select({ postUri: sql<string>`post_uri`, uri: sql<string>`uri` })
192
+
.from(sql`likes`)
193
+
.where(
194
+
and(
195
+
eq(sql`user_did`, viewerDid),
196
+
inArray(sql`post_uri`, postUris)
197
+
)
198
+
),
199
+
200
+
db.select({ postUri: sql<string>`post_uri`, uri: sql<string>`uri` })
201
+
.from(sql`reposts`)
202
+
.where(
203
+
and(
204
+
eq(sql`user_did`, viewerDid),
205
+
inArray(sql`post_uri`, postUris)
206
+
)
207
+
),
208
+
209
+
db.select({ postUri: sql<string>`post_uri` })
210
+
.from(sql`bookmarks`)
211
+
.where(
212
+
and(
213
+
eq(sql`user_did`, viewerDid),
214
+
inArray(sql`post_uri`, postUris)
215
+
)
216
+
)
217
+
]);
218
+
219
+
const result = new Map();
220
+
221
+
for (const uri of postUris) {
222
+
const state: any = {};
223
+
224
+
const like = likesData.find(l => l.postUri === uri);
225
+
if (like) {
226
+
state.likeUri = like.uri;
227
+
}
228
+
229
+
const repost = repostsData.find(r => r.postUri === uri);
230
+
if (repost) {
231
+
state.repostUri = repost.uri;
232
+
}
233
+
234
+
const bookmark = bookmarksData.find(b => b.postUri === uri);
235
+
if (bookmark) {
236
+
state.bookmarked = true;
237
+
}
238
+
239
+
result.set(uri, state);
240
+
}
241
+
242
+
return result;
243
+
}
244
+
}
+297
-36
server/services/xrpc-api.ts
+297
-36
server/services/xrpc-api.ts
···
12
12
import type { UserSettings } from "@shared/schema";
13
13
import { Hydrator } from "./hydration";
14
14
import { Views } from "./views";
15
+
import { enhancedHydrator } from "./hydration/index";
15
16
16
17
// Query schemas
17
18
const getTimelineSchema = z.object({
···
254
255
const getSuggestedFeedsSchema = z.object({
255
256
limit: z.coerce.number().min(1).max(100).default(50),
256
257
cursor: z.string().optional(),
258
+
});
259
+
260
+
const getPopularFeedGeneratorsSchema = z.object({
261
+
limit: z.coerce.number().min(1).max(100).default(50),
262
+
cursor: z.string().optional(),
263
+
query: z.string().optional(),
257
264
});
258
265
259
266
const describeFeedGeneratorSchema = z.object({
···
683
690
};
684
691
}
685
692
693
+
private async serializePostsEnhanced(posts: any[], viewerDid?: string) {
694
+
const startTime = performance.now();
695
+
696
+
if (posts.length === 0) {
697
+
return [];
698
+
}
699
+
700
+
const postUris = posts.map((p) => p.uri);
701
+
702
+
const state = await enhancedHydrator.hydratePosts(postUris, viewerDid);
703
+
704
+
const hydrationTime = performance.now() - startTime;
705
+
console.log(`[ENHANCED_HYDRATION] Hydrated ${postUris.length} posts in ${hydrationTime.toFixed(2)}ms`);
706
+
707
+
const serializedPosts = posts.map((post) => {
708
+
const hydratedPost = state.posts.get(post.uri);
709
+
const author = state.actors.get(post.authorDid);
710
+
const aggregation = state.aggregations.get(post.uri);
711
+
const viewerState = state.viewerStates.get(post.uri);
712
+
const actorViewerState = state.actorViewerStates.get(post.authorDid);
713
+
const labels = state.labels.get(post.uri) || [];
714
+
const authorLabels = state.labels.get(post.authorDid) || [];
715
+
const hydratedEmbed = state.embeds.get(post.uri);
716
+
717
+
const authorHandle = (author?.handle && typeof author.handle === 'string' && author.handle.trim() !== '')
718
+
? author.handle
719
+
: 'handle.invalid';
720
+
721
+
const record: any = {
722
+
$type: 'app.bsky.feed.post',
723
+
text: hydratedPost?.text || post.text,
724
+
createdAt: hydratedPost?.createdAt || post.createdAt.toISOString(),
725
+
};
726
+
727
+
if (hydratedPost?.embed || post.embed) {
728
+
const embedData = hydratedPost?.embed || post.embed;
729
+
if (embedData && typeof embedData === 'object' && embedData.$type) {
730
+
let transformedEmbed = { ...embedData };
731
+
732
+
if (embedData.$type === 'app.bsky.embed.images') {
733
+
transformedEmbed.images = embedData.images?.map((img: any) => ({
734
+
...img,
735
+
image: {
736
+
...img.image,
737
+
ref: {
738
+
...img.image.ref,
739
+
link: this.transformBlobToCdnUrl(img.image.ref.$link, post.authorDid, 'feed_fullsize')
740
+
}
741
+
}
742
+
}));
743
+
} else if (embedData.$type === 'app.bsky.embed.external' && embedData.external?.thumb?.ref?.$link) {
744
+
transformedEmbed.external = {
745
+
...embedData.external,
746
+
thumb: {
747
+
...embedData.external.thumb,
748
+
ref: {
749
+
...embedData.external.thumb.ref,
750
+
link: this.transformBlobToCdnUrl(embedData.external.thumb.ref.$link, post.authorDid, 'feed_thumbnail')
751
+
}
752
+
}
753
+
};
754
+
}
755
+
756
+
record.embed = transformedEmbed;
757
+
}
758
+
}
759
+
if (hydratedPost?.facets || post.facets) record.facets = hydratedPost?.facets || post.facets;
760
+
if (hydratedPost?.reply) record.reply = hydratedPost.reply;
761
+
762
+
const avatarUrl = author?.avatarUrl;
763
+
const avatarCdn = avatarUrl
764
+
? (avatarUrl.startsWith('http') ? avatarUrl : this.transformBlobToCdnUrl(avatarUrl, author.did, 'avatar'))
765
+
: undefined;
766
+
767
+
const postView: any = {
768
+
$type: 'app.bsky.feed.defs#postView',
769
+
uri: post.uri,
770
+
cid: post.cid,
771
+
author: {
772
+
$type: 'app.bsky.actor.defs#profileViewBasic',
773
+
did: post.authorDid,
774
+
handle: authorHandle,
775
+
displayName: author?.displayName ?? authorHandle,
776
+
pronouns: author?.pronouns,
777
+
avatar: avatarCdn,
778
+
viewer: actorViewerState || {},
779
+
labels: authorLabels,
780
+
createdAt: author?.createdAt?.toISOString(),
781
+
},
782
+
record,
783
+
replyCount: aggregation?.replyCount || 0,
784
+
repostCount: aggregation?.repostCount || 0,
785
+
likeCount: aggregation?.likeCount || 0,
786
+
bookmarkCount: aggregation?.bookmarkCount || 0,
787
+
quoteCount: aggregation?.quoteCount || 0,
788
+
indexedAt: hydratedPost?.indexedAt || post.indexedAt.toISOString(),
789
+
labels: labels,
790
+
viewer: viewerState ? {
791
+
$type: 'app.bsky.feed.defs#viewerState',
792
+
like: viewerState.likeUri || undefined,
793
+
repost: viewerState.repostUri || undefined,
794
+
bookmarked: viewerState.bookmarked || false,
795
+
threadMuted: viewerState.threadMuted || false,
796
+
replyDisabled: viewerState.replyDisabled || false,
797
+
embeddingDisabled: viewerState.embeddingDisabled || false,
798
+
pinned: viewerState.pinned || false,
799
+
} : {},
800
+
};
801
+
802
+
if (hydratedEmbed) {
803
+
postView.embed = hydratedEmbed;
804
+
}
805
+
806
+
return postView;
807
+
});
808
+
809
+
return serializedPosts;
810
+
}
811
+
686
812
private async serializePosts(posts: any[], viewerDid?: string) {
813
+
const useEnhancedHydration = process.env.ENHANCED_HYDRATION_ENABLED === 'true';
814
+
815
+
if (useEnhancedHydration) {
816
+
return this.serializePostsEnhanced(posts, viewerDid);
817
+
}
818
+
687
819
if (posts.length === 0) {
688
820
return [];
689
821
}
···
995
1127
items.unshift(pinnedItem);
996
1128
}
997
1129
998
-
// Hydrate the feed items
999
-
const hydrator = new Hydrator();
1000
-
const views = new Views();
1130
+
// Extract post URIs for hydration
1131
+
const postUris = items.map(item => item.post.uri);
1132
+
1133
+
// Fetch posts from storage for serialization
1134
+
const posts = await storage.getPosts(postUris);
1001
1135
1002
-
const hydrationState = await hydrator.hydrateFeedItems(items, viewerDid);
1136
+
// Fetch reposts and reposter profiles for reason construction
1137
+
const repostUris = items
1138
+
.filter(item => item.repost)
1139
+
.map(item => item.repost!.uri);
1140
+
1141
+
const reposts = await Promise.all(
1142
+
repostUris.map(uri => storage.getRepost(uri))
1143
+
);
1144
+
const repostsByUri = new Map(
1145
+
reposts.filter(Boolean).map(r => [r!.uri, r!])
1146
+
);
1003
1147
1004
-
// Filter out blocked/muted content
1005
-
const filteredItems = items.filter((item) => {
1006
-
const bam = views.feedItemBlocksAndMutes(item, hydrationState);
1007
-
return (
1008
-
!bam.authorBlocked &&
1009
-
!bam.originatorBlocked &&
1010
-
(!bam.authorMuted || bam.originatorMuted) // repost of muted content
1011
-
);
1012
-
});
1148
+
// Get all reposter DIDs for profile fetching
1149
+
const reposterDids = Array.from(repostsByUri.values()).map(r => r.repostedByDid);
1150
+
const reposters = await Promise.all(
1151
+
reposterDids.map(did => storage.getUser(did))
1152
+
);
1153
+
const repostersByDid = new Map(
1154
+
reposters.filter(Boolean).map(u => [u!.did, u!])
1155
+
);
1013
1156
1014
-
// Handle posts_and_author_threads filter
1015
-
if (params.filter === 'posts_and_author_threads') {
1016
-
const { SelfThreadTracker } = await import('../types/feed');
1017
-
const selfThread = new SelfThreadTracker(filteredItems, hydrationState);
1018
-
items = filteredItems.filter((item) => {
1019
-
return (
1020
-
item.repost ||
1021
-
item.authorPinned ||
1022
-
selfThread.ok(item.post.uri)
1023
-
);
1024
-
});
1025
-
} else {
1026
-
items = filteredItems;
1157
+
// Apply content filtering if viewer is authenticated
1158
+
let filteredPosts = posts;
1159
+
if (viewerDid) {
1160
+
const settings = await storage.getUserSettings(viewerDid);
1161
+
if (settings) {
1162
+
filteredPosts = contentFilter.filterPosts(posts, settings);
1163
+
}
1027
1164
}
1028
1165
1029
-
// Convert to feed view posts
1166
+
// Serialize posts with enhanced hydration (when flag is enabled)
1167
+
const serializedPosts = await this.serializePosts(filteredPosts, viewerDid);
1168
+
const postsByUri = new Map(serializedPosts.map(p => [p.uri, p]));
1169
+
1170
+
// Build feed with reposts and pinned posts
1030
1171
const feed = items
1031
-
.map((item) => views.feedViewPost(item, hydrationState))
1172
+
.map(item => {
1173
+
const post = postsByUri.get(item.post.uri);
1174
+
if (!post) return null;
1175
+
1176
+
let reason: any = undefined;
1177
+
1178
+
// Handle pinned post reason
1179
+
if (item.authorPinned) {
1180
+
reason = {
1181
+
$type: 'app.bsky.feed.defs#reasonPin',
1182
+
};
1183
+
}
1184
+
// Handle repost reason
1185
+
else if (item.repost) {
1186
+
const repost = repostsByUri.get(item.repost.uri);
1187
+
const reposter = repost ? repostersByDid.get(repost.repostedByDid) : null;
1188
+
1189
+
if (repost && reposter) {
1190
+
reason = {
1191
+
$type: 'app.bsky.feed.defs#reasonRepost',
1192
+
by: {
1193
+
$type: 'app.bsky.actor.defs#profileViewBasic',
1194
+
did: reposter.did,
1195
+
handle: reposter.handle,
1196
+
displayName: reposter.displayName,
1197
+
avatar: reposter.avatarUrl ? this.transformBlobToCdnUrl(reposter.avatarUrl, reposter.did, 'avatar') : undefined,
1198
+
},
1199
+
indexedAt: repost.indexedAt.toISOString(),
1200
+
};
1201
+
}
1202
+
}
1203
+
1204
+
return {
1205
+
post,
1206
+
...(reason && { reason }),
1207
+
};
1208
+
})
1032
1209
.filter(Boolean);
1033
1210
1034
1211
res.json({
···
1719
1896
const relationships = await storage.getRelationships(actorDid, targetDids);
1720
1897
1721
1898
res.json({
1722
-
actor: params.actor,
1899
+
actor: actorDid,
1723
1900
relationships: Array.from(relationships.entries()).map(([did, rel]) => ({
1901
+
$type: 'app.bsky.graph.defs#relationship',
1724
1902
did,
1725
-
following: rel.following
1726
-
? `at://${actorDid}/app.bsky.graph.follow/${did}`
1727
-
: undefined,
1728
-
followedBy: rel.followedBy
1729
-
? `at://${did}/app.bsky.graph.follow/${actorDid}`
1730
-
: undefined,
1903
+
following: rel.following || undefined,
1904
+
followedBy: rel.followedBy || undefined,
1905
+
blocking: rel.blocking || undefined,
1906
+
blockedBy: rel.blockedBy || undefined,
1907
+
muted: rel.muting || undefined,
1731
1908
})),
1732
1909
});
1733
1910
} catch (error) {
···
2260
2437
}
2261
2438
}
2262
2439
2440
+
async getPopularFeedGenerators(req: Request, res: Response) {
2441
+
try {
2442
+
const params = getPopularFeedGeneratorsSchema.parse(req.query);
2443
+
2444
+
let generators: any[];
2445
+
let cursor: string | undefined;
2446
+
2447
+
// If query is provided, search for feed generators by name/description
2448
+
// Otherwise, return suggested feeds (popular by default)
2449
+
if (params.query && params.query.trim()) {
2450
+
const searchResults = await storage.searchFeedGeneratorsByName(
2451
+
params.query.trim(),
2452
+
params.limit,
2453
+
params.cursor
2454
+
);
2455
+
generators = searchResults.feedGenerators;
2456
+
cursor = searchResults.cursor;
2457
+
} else {
2458
+
const suggestedResults = await storage.getSuggestedFeeds(
2459
+
params.limit,
2460
+
params.cursor,
2461
+
);
2462
+
generators = suggestedResults.generators;
2463
+
cursor = suggestedResults.cursor;
2464
+
}
2465
+
2466
+
const feeds = await Promise.all(
2467
+
generators.map(async (generator) => {
2468
+
const creator = await storage.getUser(generator.creatorDid);
2469
+
2470
+
const creatorView: any = {
2471
+
did: generator.creatorDid,
2472
+
handle:
2473
+
creator?.handle ||
2474
+
`${generator.creatorDid.replace(/:/g, '-')}.invalid`,
2475
+
};
2476
+
if (creator?.displayName)
2477
+
creatorView.displayName = creator.displayName;
2478
+
if (creator?.avatarUrl) creatorView.avatar = this.transformBlobToCdnUrl(creator.avatarUrl, creator.did, 'avatar');
2479
+
2480
+
const view: any = {
2481
+
uri: generator.uri,
2482
+
cid: generator.cid,
2483
+
did: generator.did,
2484
+
creator: creatorView,
2485
+
displayName: generator.displayName,
2486
+
likeCount: generator.likeCount,
2487
+
indexedAt: generator.indexedAt.toISOString(),
2488
+
};
2489
+
if (generator.description) view.description = generator.description;
2490
+
if (generator.avatarUrl) view.avatar = this.transformBlobToCdnUrl(generator.avatarUrl, generator.creatorDid, 'avatar');
2491
+
2492
+
return view;
2493
+
}),
2494
+
);
2495
+
2496
+
res.json({ cursor, feeds });
2497
+
} catch (error) {
2498
+
this._handleError(res, error, 'getPopularFeedGenerators');
2499
+
}
2500
+
}
2501
+
2263
2502
// Starter Pack endpoints
2264
2503
async getStarterPack(req: Request, res: Response) {
2265
2504
try {
···
2879
3118
2880
3119
async getUnspeccedConfig(req: Request, res: Response) {
2881
3120
try {
2882
-
res.json({ liveNowConfig: { enabled: false } });
3121
+
// Get country code from request headers or IP
3122
+
// Default to US for self-hosted instances
3123
+
const countryCode = req.headers['cf-ipcountry'] ||
3124
+
req.headers['x-country-code'] ||
3125
+
process.env.DEFAULT_COUNTRY_CODE ||
3126
+
'US';
3127
+
3128
+
const regionCode = req.headers['cf-region-code'] ||
3129
+
req.headers['x-region-code'] ||
3130
+
process.env.DEFAULT_REGION_CODE ||
3131
+
'';
3132
+
3133
+
// For self-hosted instances, disable age restrictions unless explicitly configured
3134
+
const isAgeBlockedGeo = process.env.AGE_BLOCKED_GEOS?.split(',')?.includes(countryCode.toString()) || false;
3135
+
const isAgeRestrictedGeo = process.env.AGE_RESTRICTED_GEOS?.split(',')?.includes(countryCode.toString()) || false;
3136
+
3137
+
res.json({
3138
+
liveNowConfig: { enabled: false },
3139
+
countryCode: countryCode.toString().substring(0, 2),
3140
+
regionCode: regionCode ? regionCode.toString() : undefined,
3141
+
isAgeBlockedGeo,
3142
+
isAgeRestrictedGeo,
3143
+
});
2883
3144
} catch (error) {
2884
3145
this._handleError(res, error, 'getUnspeccedConfig');
2885
3146
}
+166
-2
server/storage.ts
+166
-2
server/storage.ts
···
1
-
import { users, posts, likes, reposts, bookmarks, follows, blocks, mutes, listMutes, listBlocks, threadMutes, userPreferences, sessions, userSettings, labels, labelDefinitions, labelEvents, moderationReports, moderationActions, moderatorAssignments, notifications, lists, listItems, feedGenerators, starterPacks, labelerServices, pushSubscriptions, videoJobs, firehoseCursor, feedItems, postAggregations, postViewerStates, threadContexts, type User, type InsertUser, type Post, type InsertPost, type Like, type InsertLike, type Repost, type InsertRepost, type Follow, type InsertFollow, type Block, type InsertBlock, type Mute, type InsertMute, type ListMute, type InsertListMute, type ListBlock, type InsertListBlock, type ThreadMute, type InsertThreadMute, type UserPreferences, type InsertUserPreferences, type Session, type InsertSession, type UserSettings, type InsertUserSettings, type Label, type InsertLabel, type LabelDefinition, type InsertLabelDefinition, type LabelEvent, type InsertLabelEvent, type ModerationReport, type InsertModerationReport, type ModerationAction, type InsertModerationAction, type ModeratorAssignment, type InsertModeratorAssignment, type Notification, type InsertNotification, type List, type InsertList, type ListItem, type InsertListItem, type FeedGenerator, type InsertFeedGenerator, type StarterPack, type InsertStarterPack, type LabelerService, type InsertLabelerService, type PushSubscription, type InsertPushSubscription, type VideoJob, type InsertVideoJob, type FirehoseCursor, type InsertFirehoseCursor, type Bookmark, insertBookmarkSchema, type FeedItem, type InsertFeedItem, type PostAggregation, type InsertPostAggregation, type PostViewerState, type InsertPostViewerState, type ThreadContext, type InsertThreadContext } from "@shared/schema";
1
+
import { users, posts, likes, reposts, bookmarks, quotes, verifications, activitySubscriptions, follows, blocks, mutes, listMutes, listBlocks, threadMutes, userPreferences, sessions, userSettings, labels, labelDefinitions, labelEvents, moderationReports, moderationActions, moderatorAssignments, notifications, lists, listItems, feedGenerators, starterPacks, labelerServices, pushSubscriptions, videoJobs, firehoseCursor, feedItems, postAggregations, postViewerStates, threadContexts, type User, type InsertUser, type Post, type InsertPost, type Like, type InsertLike, type Repost, type InsertRepost, type Follow, type InsertFollow, type Block, type InsertBlock, type Mute, type InsertMute, type ListMute, type InsertListMute, type ListBlock, type InsertListBlock, type ThreadMute, type InsertThreadMute, type UserPreferences, type InsertUserPreferences, type Session, type InsertSession, type UserSettings, type InsertUserSettings, type Label, type InsertLabel, type LabelDefinition, type InsertLabelDefinition, type LabelEvent, type InsertLabelEvent, type ModerationReport, type InsertModerationReport, type ModerationAction, type InsertModerationAction, type ModeratorAssignment, type InsertModeratorAssignment, type Notification, type InsertNotification, type List, type InsertList, type ListItem, type InsertListItem, type FeedGenerator, type InsertFeedGenerator, type StarterPack, type InsertStarterPack, type LabelerService, type InsertLabelerService, type PushSubscription, type InsertPushSubscription, type VideoJob, type InsertVideoJob, type FirehoseCursor, type InsertFirehoseCursor, type Bookmark, insertBookmarkSchema, type Quote, type InsertQuote, type Verification, type InsertVerification, type ActivitySubscription, type InsertActivitySubscription, type FeedItem, type InsertFeedItem, type PostAggregation, type InsertPostAggregation, type PostViewerState, type InsertPostViewerState, type ThreadContext, type InsertThreadContext } from "@shared/schema";
2
2
import { db, pool, type DbConnection } from "./db";
3
-
import { eq, desc, and, sql, inArray, isNull } from "drizzle-orm";
3
+
import { eq, desc, and, or, sql, inArray, isNull } from "drizzle-orm";
4
4
import { encryptionService } from "./services/encryption";
5
5
import { sanitizeObject } from "./utils/sanitize";
6
6
import { cacheService } from "./services/cache";
···
63
63
getBookmark(uri: string): Promise<Bookmark | undefined>;
64
64
getBookmarks(userDid: string, limit?: number, cursor?: string): Promise<{ bookmarks: Bookmark[], cursor?: string }>;
65
65
getBookmarkUri(userDid: string, postUri: string): Promise<string | undefined>;
66
+
67
+
// Quote operations
68
+
createQuote(quote: InsertQuote): Promise<Quote>;
69
+
deleteQuote(uri: string): Promise<void>;
70
+
getQuote(uri: string): Promise<Quote | undefined>;
71
+
getQuotes(postUri: string, limit?: number, cursor?: string): Promise<{ quotes: Quote[], cursor?: string }>;
72
+
73
+
// Verification operations
74
+
createVerification(verification: InsertVerification): Promise<Verification>;
75
+
deleteVerification(uri: string): Promise<void>;
76
+
getVerification(uri: string): Promise<Verification | undefined>;
77
+
getVerificationBySubject(subjectDid: string): Promise<Verification | undefined>;
78
+
79
+
// Activity subscription operations
80
+
createActivitySubscription(subscription: InsertActivitySubscription): Promise<ActivitySubscription>;
81
+
deleteActivitySubscription(uri: string): Promise<void>;
82
+
getActivitySubscription(uri: string): Promise<ActivitySubscription | undefined>;
83
+
getActivitySubscriptions(subscriberDid: string, limit?: number, cursor?: string): Promise<{ subscriptions: ActivitySubscription[], cursor?: string }>;
66
84
67
85
// Follow operations
68
86
createFollow(follow: InsertFollow): Promise<Follow>;
···
205
223
getFeedGenerators(uris: string[]): Promise<FeedGenerator[]>;
206
224
getActorFeeds(actorDid: string, limit?: number, cursor?: string): Promise<{ generators: FeedGenerator[], cursor?: string }>;
207
225
getSuggestedFeeds(limit?: number, cursor?: string): Promise<{ generators: FeedGenerator[], cursor?: string }>;
226
+
searchFeedGeneratorsByName(q: string, limit?: number, cursor?: string): Promise<{ feedGenerators: FeedGenerator[], cursor?: string }>;
208
227
updateFeedGenerator(uri: string, data: Partial<InsertFeedGenerator>): Promise<FeedGenerator | undefined>;
209
228
210
229
// Starter pack operations
···
964
983
.where(and(eq(bookmarks.userDid, userDid), eq(bookmarks.postUri, postUri)))
965
984
.limit(1);
966
985
return row?.uri;
986
+
}
987
+
988
+
// Quote operations
989
+
async createQuote(quote: InsertQuote): Promise<Quote> {
990
+
const [row] = await this.db
991
+
.insert(quotes)
992
+
.values(quote)
993
+
.onConflictDoNothing()
994
+
.returning();
995
+
return row as Quote;
996
+
}
997
+
998
+
async deleteQuote(uri: string): Promise<void> {
999
+
await this.db.delete(quotes).where(eq(quotes.uri, uri));
1000
+
}
1001
+
1002
+
async getQuote(uri: string): Promise<Quote | undefined> {
1003
+
const [row] = await this.db
1004
+
.select()
1005
+
.from(quotes)
1006
+
.where(eq(quotes.uri, uri))
1007
+
.limit(1);
1008
+
return row as Quote | undefined;
1009
+
}
1010
+
1011
+
async getQuotes(postUri: string, limit = 50, cursor?: string): Promise<{ quotes: Quote[]; cursor?: string }> {
1012
+
const conditions = [eq(quotes.quotedUri, postUri)];
1013
+
if (cursor) {
1014
+
conditions.push(sql`${quotes.indexedAt} < ${new Date(cursor)}`);
1015
+
}
1016
+
const results = await this.db
1017
+
.select()
1018
+
.from(quotes)
1019
+
.where(and(...conditions))
1020
+
.orderBy(desc(quotes.indexedAt))
1021
+
.limit(limit + 1);
1022
+
const hasMore = results.length > limit;
1023
+
const items = hasMore ? results.slice(0, limit) : results;
1024
+
const nextCursor = hasMore ? items[items.length - 1].indexedAt.toISOString() : undefined;
1025
+
return { quotes: items as Quote[], cursor: nextCursor };
1026
+
}
1027
+
1028
+
// Verification operations
1029
+
async createVerification(verification: InsertVerification): Promise<Verification> {
1030
+
const [row] = await this.db
1031
+
.insert(verifications)
1032
+
.values(verification)
1033
+
.onConflictDoNothing()
1034
+
.returning();
1035
+
return row as Verification;
1036
+
}
1037
+
1038
+
async deleteVerification(uri: string): Promise<void> {
1039
+
await this.db.delete(verifications).where(eq(verifications.uri, uri));
1040
+
}
1041
+
1042
+
async getVerification(uri: string): Promise<Verification | undefined> {
1043
+
const [row] = await this.db
1044
+
.select()
1045
+
.from(verifications)
1046
+
.where(eq(verifications.uri, uri))
1047
+
.limit(1);
1048
+
return row as Verification | undefined;
1049
+
}
1050
+
1051
+
async getVerificationBySubject(subjectDid: string): Promise<Verification | undefined> {
1052
+
const [row] = await this.db
1053
+
.select()
1054
+
.from(verifications)
1055
+
.where(eq(verifications.subjectDid, subjectDid))
1056
+
.limit(1);
1057
+
return row as Verification | undefined;
1058
+
}
1059
+
1060
+
// Activity subscription operations
1061
+
async createActivitySubscription(subscription: InsertActivitySubscription): Promise<ActivitySubscription> {
1062
+
const [row] = await this.db
1063
+
.insert(activitySubscriptions)
1064
+
.values(subscription)
1065
+
.onConflictDoNothing()
1066
+
.returning();
1067
+
return row as ActivitySubscription;
1068
+
}
1069
+
1070
+
async deleteActivitySubscription(uri: string): Promise<void> {
1071
+
await this.db.delete(activitySubscriptions).where(eq(activitySubscriptions.uri, uri));
1072
+
}
1073
+
1074
+
async getActivitySubscription(uri: string): Promise<ActivitySubscription | undefined> {
1075
+
const [row] = await this.db
1076
+
.select()
1077
+
.from(activitySubscriptions)
1078
+
.where(eq(activitySubscriptions.uri, uri))
1079
+
.limit(1);
1080
+
return row as ActivitySubscription | undefined;
1081
+
}
1082
+
1083
+
async getActivitySubscriptions(subscriberDid: string, limit = 50, cursor?: string): Promise<{ subscriptions: ActivitySubscription[]; cursor?: string }> {
1084
+
const conditions = [eq(activitySubscriptions.subscriberDid, subscriberDid)];
1085
+
if (cursor) {
1086
+
conditions.push(sql`${activitySubscriptions.indexedAt} < ${new Date(cursor)}`);
1087
+
}
1088
+
const results = await this.db
1089
+
.select()
1090
+
.from(activitySubscriptions)
1091
+
.where(and(...conditions))
1092
+
.orderBy(desc(activitySubscriptions.indexedAt))
1093
+
.limit(limit + 1);
1094
+
const hasMore = results.length > limit;
1095
+
const items = hasMore ? results.slice(0, limit) : results;
1096
+
const nextCursor = hasMore ? items[items.length - 1].indexedAt.toISOString() : undefined;
1097
+
return { subscriptions: items as ActivitySubscription[], cursor: nextCursor };
967
1098
}
968
1099
969
1100
async createFollow(follow: InsertFollow): Promise<Follow> {
···
2208
2339
: undefined;
2209
2340
2210
2341
return { generators: results, cursor: nextCursor };
2342
+
}
2343
+
2344
+
async searchFeedGeneratorsByName(q: string, limit = 25, cursor?: string): Promise<{ feedGenerators: FeedGenerator[], cursor?: string }> {
2345
+
const searchPattern = `%${q.toLowerCase()}%`;
2346
+
const conditions = [
2347
+
or(
2348
+
sql`LOWER(${feedGenerators.displayName}) LIKE ${searchPattern}`,
2349
+
sql`LOWER(${feedGenerators.description}) LIKE ${searchPattern}`
2350
+
)
2351
+
];
2352
+
2353
+
// Use composite cursor matching the ordering: likeCount DESC, indexedAt DESC
2354
+
if (cursor) {
2355
+
const [likeCount, indexedAt] = cursor.split('::');
2356
+
conditions.push(
2357
+
sql`(${feedGenerators.likeCount}, ${feedGenerators.indexedAt}) < (${parseInt(likeCount)}, ${new Date(indexedAt)})`
2358
+
);
2359
+
}
2360
+
2361
+
const results = await db
2362
+
.select()
2363
+
.from(feedGenerators)
2364
+
.where(and(...conditions))
2365
+
.orderBy(desc(feedGenerators.likeCount), desc(feedGenerators.indexedAt))
2366
+
.limit(limit + 1);
2367
+
2368
+
const hasMore = results.length > limit;
2369
+
const feedGeneratorList = hasMore ? results.slice(0, limit) : results;
2370
+
const nextCursor = hasMore
2371
+
? `${feedGeneratorList[feedGeneratorList.length - 1].likeCount}::${feedGeneratorList[feedGeneratorList.length - 1].indexedAt.toISOString()}`
2372
+
: undefined;
2373
+
2374
+
return { feedGenerators: feedGeneratorList, cursor: nextCursor };
2211
2375
}
2212
2376
2213
2377
async updateFeedGenerator(uri: string, data: Partial<InsertFeedGenerator>): Promise<FeedGenerator | undefined> {
+25
start-with-redis.sh
+25
start-with-redis.sh
···
1
+
#!/bin/bash
2
+
3
+
# Start Redis in the background
4
+
echo "[STARTUP] Starting Redis server..."
5
+
redis-server --daemonize yes --port 6379 --dir /tmp --save "" --appendonly no
6
+
7
+
# Wait for Redis to be ready
8
+
for i in {1..10}; do
9
+
if redis-cli ping > /dev/null 2>&1; then
10
+
echo "[STARTUP] Redis is ready!"
11
+
break
12
+
fi
13
+
echo "[STARTUP] Waiting for Redis to start... ($i/10)"
14
+
sleep 1
15
+
done
16
+
17
+
# Check if Redis is running
18
+
if ! redis-cli ping > /dev/null 2>&1; then
19
+
echo "[STARTUP] ERROR: Redis failed to start!"
20
+
exit 1
21
+
fi
22
+
23
+
# Start the application
24
+
echo "[STARTUP] Starting Node.js application..."
25
+
NODE_ENV=development tsx server/index.ts