[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix endpoints

+512 -232
+1
services/appview/package.json
··· 34 34 "mongoose": "^8.12.1", 35 35 "multiformats": "^9.9.0", 36 36 "pino": "^9.6.0", 37 + "pino-pretty": "^13.0.0", 37 38 "uint8arrays": "^5.1.0", 38 39 "ws": "^8.18.1", 39 40 "zod": "^3.24.2"
+78 -1
services/appview/pnpm-lock.yaml
··· 72 72 pino: 73 73 specifier: ^9.6.0 74 74 version: 9.6.0 75 + pino-pretty: 76 + specifier: ^13.0.0 77 + version: 13.0.0 75 78 uint8arrays: 76 79 specifier: ^5.1.0 77 80 version: 5.1.0 ··· 1241 1244 resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 1242 1245 dev: true 1243 1246 1247 + /colorette@2.0.20: 1248 + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} 1249 + dev: false 1250 + 1244 1251 /commander@9.5.0: 1245 1252 resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} 1246 1253 engines: {node: ^12.20.0 || >=14} ··· 1284 1291 shebang-command: 2.0.0 1285 1292 which: 2.0.2 1286 1293 dev: true 1294 + 1295 + /dateformat@4.6.3: 1296 + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} 1297 + dev: false 1287 1298 1288 1299 /debug@2.6.9: 1289 1300 resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} ··· 1353 1364 /encodeurl@2.0.0: 1354 1365 resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} 1355 1366 engines: {node: '>= 0.8'} 1367 + dev: false 1368 + 1369 + /end-of-stream@1.4.4: 1370 + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} 1371 + dependencies: 1372 + once: 1.4.0 1356 1373 dev: false 1357 1374 1358 1375 /envalid@8.0.0: ··· 1579 1596 - supports-color 1580 1597 dev: false 1581 1598 1599 + /fast-copy@3.0.2: 1600 + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} 1601 + dev: false 1602 + 1582 1603 /fast-deep-equal@3.1.3: 1583 1604 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 1584 1605 dev: true ··· 1607 1628 engines: {node: '>=6'} 1608 1629 dev: false 1609 1630 1631 + /fast-safe-stringify@2.1.1: 1632 + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} 1633 + dev: false 1634 + 1610 1635 /fastq@1.19.1: 1611 1636 resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} 1612 1637 dependencies: ··· 1757 1782 function-bind: 1.1.2 1758 1783 dev: false 1759 1784 1785 + /help-me@5.0.0: 1786 + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 1787 + dev: false 1788 + 1760 1789 /hono@4.7.4: 1761 1790 resolution: {integrity: sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg==} 1762 1791 engines: {node: '>=16.9.0'} ··· 1856 1885 resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 1857 1886 dev: false 1858 1887 1888 + /joycon@3.1.1: 1889 + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} 1890 + engines: {node: '>=10'} 1891 + dev: false 1892 + 1859 1893 /js-yaml@4.1.0: 1860 1894 resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} 1861 1895 hasBin: true ··· 1987 2021 brace-expansion: 2.0.1 1988 2022 dev: true 1989 2023 2024 + /minimist@1.2.8: 2025 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 2026 + dev: false 2027 + 1990 2028 /mkdirp@1.0.4: 1991 2029 resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} 1992 2030 engines: {node: '>=10'} ··· 2120 2158 ee-first: 1.1.1 2121 2159 dev: false 2122 2160 2161 + /once@1.4.0: 2162 + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} 2163 + dependencies: 2164 + wrappy: 1.0.2 2165 + dev: false 2166 + 2123 2167 /optionator@0.9.4: 2124 2168 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} 2125 2169 engines: {node: '>= 0.8.0'} ··· 2241 2285 split2: 4.2.0 2242 2286 dev: false 2243 2287 2288 + /pino-pretty@13.0.0: 2289 + resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==} 2290 + hasBin: true 2291 + dependencies: 2292 + colorette: 2.0.20 2293 + dateformat: 4.6.3 2294 + fast-copy: 3.0.2 2295 + fast-safe-stringify: 2.1.1 2296 + help-me: 5.0.0 2297 + joycon: 3.1.1 2298 + minimist: 1.2.8 2299 + on-exit-leak-free: 2.1.2 2300 + pino-abstract-transport: 2.0.0 2301 + pump: 3.0.2 2302 + secure-json-parse: 2.7.0 2303 + sonic-boom: 4.2.0 2304 + strip-json-comments: 3.1.1 2305 + dev: false 2306 + 2244 2307 /pino-std-serializers@6.2.2: 2245 2308 resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} 2246 2309 dev: false ··· 2345 2408 resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} 2346 2409 dependencies: 2347 2410 punycode: 2.3.1 2411 + dev: false 2412 + 2413 + /pump@3.0.2: 2414 + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} 2415 + dependencies: 2416 + end-of-stream: 1.4.4 2417 + once: 1.4.0 2348 2418 dev: false 2349 2419 2350 2420 /punycode@2.3.1: ··· 2434 2504 resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 2435 2505 dev: false 2436 2506 2507 + /secure-json-parse@2.7.0: 2508 + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} 2509 + dev: false 2510 + 2437 2511 /semver@7.7.1: 2438 2512 resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} 2439 2513 engines: {node: '>=10'} ··· 2570 2644 /strip-json-comments@3.1.1: 2571 2645 resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} 2572 2646 engines: {node: '>=8'} 2573 - dev: true 2574 2647 2575 2648 /supports-color@7.2.0: 2576 2649 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} ··· 2741 2814 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 2742 2815 engines: {node: '>=0.10.0'} 2743 2816 dev: true 2817 + 2818 + /wrappy@1.0.2: 2819 + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 2820 + dev: false 2744 2821 2745 2822 /ws@8.18.1: 2746 2823 resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
+207 -157
services/appview/src/routes/so/sprk/actor/getProfile.ts
··· 15 15 '/xrpc/so.sprk.actor.getProfile', 16 16 optionalAuthMiddleware, 17 17 async (c) => { 18 - const actorIdentifier = c.req.query('actor') 18 + const actorParam = c.req.query('actor') 19 19 const viewerDid = c.get('did') as string | undefined 20 - const now = new Date().toISOString() 21 20 22 - if (!actorIdentifier) { 21 + if (!actorParam) { 23 22 return c.json({ error: 'Actor not provided' }, 400) 24 23 } 25 24 26 - // Resolve actor DID from handle or DID 27 - let actorDid: string 28 - try { 29 - if (isValidHandle(actorIdentifier)) { 30 - const didDoc = await ctx.resolver.resolveHandleToDidDoc(actorIdentifier) 31 - actorDid = didDoc.did 32 - } else { 33 - ensureValidDid(actorIdentifier) 34 - actorDid = actorIdentifier 25 + let actorDidDoc 26 + if (isValidHandle(actorParam)) { 27 + actorDidDoc = await ctx.resolver.resolveHandleToDidDoc(actorParam) 28 + } else { 29 + try { 30 + ensureValidDid(actorParam) 31 + actorDidDoc = await ctx.resolver.resolveDidToDidDoc(actorParam) 32 + } catch (err) { 33 + return c.json({ error: 'Invalid actor' }, 400) 35 34 } 36 - } catch (err) { 37 - return c.json({ error: 'Invalid actor' }, 400) 38 35 } 39 36 40 - // Use the indexing service - it will handle recency checks internally 41 - try { 42 - await ctx.indexingService.indexHandle(actorDid, now) 43 - } catch (error) { 44 - ctx.logger.warn({ error, did: actorDid }, 'Failed to index handle') 45 - // Continue anyway - we might still have data 37 + const actorDid = actorDidDoc.did 38 + 39 + const now = new Date().toISOString() 40 + 41 + await ctx.indexingService.indexHandle(actorDid, now) 42 + 43 + // First check if actor exists and has profile 44 + let actorDoc = await ctx.db.models.Actor.findOne({ 45 + did: actorDid, 46 + }) 47 + .populate('profile') 48 + .lean() 49 + 50 + // If no actor or no profile, try indexing 51 + if (!actorDoc || !actorDoc.profile) { 52 + try { 53 + ctx.logger.info({ did: actorDid }, 'No profile found, attempting to index') 54 + await ctx.indexingService.indexHandle(actorDid, now, true) 55 + 56 + // Refetch after indexing 57 + actorDoc = await ctx.db.models.Actor.findOne({ 58 + did: actorDid, 59 + }) 60 + .populate('profile') 61 + .lean() 62 + 63 + ctx.logger.info({ 64 + did: actorDid, 65 + hasActor: !!actorDoc, 66 + hasProfile: !!(actorDoc?.profile) 67 + }, 'State after indexing') 68 + } catch (error) { 69 + ctx.logger.error({ error, did: actorDid }, 'Failed to index handle') 70 + } 46 71 } 47 72 48 - // Find the actor with populated profile 49 - const actor = await ctx.db.models.Actor.findOne({ did: actorDid }) 50 - .populate('profile') 51 - .lean() 52 - 53 - if (!actor) { 73 + if (!actorDoc) { 54 74 return c.json({ error: 'Actor not found' }, 404) 55 75 } 56 - 57 - return generateProfileResponse(c, ctx, actor, viewerDid, actorDid) 58 - }, 59 - ) 60 76 61 - return router 62 - } 77 + if (!actorDoc.profile) { 78 + return c.json({ error: 'Profile not found' }, 404) 79 + } 63 80 64 - // Helper function to handle the rest of the profile rendering logic 65 - async function generateProfileResponse( 66 - c: any, 67 - ctx: AppContext, 68 - actor: any, 69 - viewerDid: string | undefined, 70 - actorDid: string 71 - ) { 72 - const profile = actor.profile as any 73 - 74 - // Build viewer state if a user is authenticated 75 - const viewer: SoSprkActorDefs.ViewerState = {} 81 + const profile = actorDoc.profile 76 82 77 - if (viewerDid) { 78 - // Check if viewer follows this profile 79 - const follow = await ctx.db.models.Follow.findOne({ 80 - subject: actorDid, 81 - authorDid: viewerDid, 82 - }) 83 - if (follow) { 84 - viewer.following = follow.uri 85 - } 83 + // Get follower count 84 + const followersCount = actorDoc.followersCount || 0 86 85 87 - // Check if this profile follows the viewer 88 - const followedBy = await ctx.db.models.Follow.findOne({ 89 - subject: viewerDid, 90 - authorDid: actorDid, 91 - }) 92 - if (followedBy) { 93 - viewer.followedBy = followedBy.uri 94 - } 86 + // Get follows count 87 + const followsCount = actorDoc.followingCount || 0 95 88 96 - // Check if viewer has blocked this profile 97 - const block = await ctx.db.models.Block.findOne({ 98 - subject: actorDid, 99 - authorDid: viewerDid, 100 - }) 101 - if (block) { 102 - viewer.blocking = block.uri 103 - } 89 + // Get posts count 90 + const postsCount = actorDoc.postsCount || 0 104 91 105 - // Check if this profile has blocked the viewer 106 - const blockedBy = await ctx.db.models.Block.findOne({ 107 - subject: viewerDid, 108 - authorDid: actorDid, 109 - }) 110 - if (blockedBy) { 111 - viewer.blockedBy = true 112 - } 92 + // Use actor's handle if available, otherwise resolve from DID 93 + const profileHandle = actorDoc.handle || await ctx.resolver.resolveDidToHandle(actorDid) 113 94 114 - // Get known followers only if profile exists 115 - if (profile && actor.followersCount > 0) { 116 - // Get the followers of this profile 117 - const followers = await ctx.db.models.Follow.find({ 118 - subject: actorDid, 119 - }).lean() 95 + // Build viewer state if a user is authenticated 96 + const viewer: SoSprkActorDefs.ViewerState = {} 120 97 121 - const followerDids = followers.map((f) => f.authorDid) 98 + if (viewerDid) { 99 + // Check if viewer follows this profile 100 + const follow = await ctx.db.models.Follow.findOne({ 101 + subject: actorDid, 102 + authorDid: viewerDid, 103 + }) 104 + if (follow) { 105 + viewer.following = follow.uri 106 + } 122 107 123 - // Check which of these followers the viewer follows 124 - const knownFollowsQuery = await ctx.db.models.Follow.find({ 125 - subject: { $in: followerDids }, 126 - authorDid: viewerDid, 127 - }).lean() 108 + // Check if this profile follows the viewer 109 + const followedBy = await ctx.db.models.Follow.findOne({ 110 + subject: viewerDid, 111 + authorDid: actorDid, 112 + }) 113 + if (followedBy) { 114 + viewer.followedBy = followedBy.uri 115 + } 128 116 129 - if (knownFollowsQuery.length > 0) { 130 - const knownFollowerDids = knownFollowsQuery.map((f) => f.subject) 117 + // Check if viewer has blocked this profile 118 + const block = await ctx.db.models.Block.findOne({ 119 + subject: actorDid, 120 + authorDid: viewerDid, 121 + }) 122 + if (block) { 123 + viewer.blocking = block.uri 124 + } 131 125 132 - // Get actors for known followers 133 - const knownFollowerActors = await ctx.db.models.Actor.find({ 134 - did: { $in: knownFollowerDids }, 126 + // Check if this profile has blocked the viewer 127 + const blockedBy = await ctx.db.models.Block.findOne({ 128 + subject: viewerDid, 129 + authorDid: actorDid, 135 130 }) 136 - .populate('profile') 137 - .limit(3) 138 - .lean() 131 + if (blockedBy) { 132 + viewer.blockedBy = true 133 + } 134 + 135 + // Get known followers (followers of the profile that the viewer also follows) 136 + if (followersCount > 0) { 137 + // Get the followers of this profile 138 + const followers = await ctx.db.models.Follow.find({ 139 + subject: actorDid, 140 + }).lean() 141 + 142 + const followerDids = followers.map((f) => f.authorDid) 143 + 144 + // Check which of these followers the viewer follows 145 + const knownFollowsQuery = await ctx.db.models.Follow.find({ 146 + subject: { $in: followerDids }, 147 + authorDid: viewerDid, 148 + }).lean() 139 149 140 - const knownFollowersBasic = knownFollowerActors 141 - .filter(a => a.profile) 142 - .map((a) => { 143 - const p = a.profile as any 144 - return { 145 - did: a.did, 146 - handle: a.handle || a.did, 147 - displayName: p?.displayName, 148 - avatar: p?.avatar 149 - ? `https://media.sprk.so/avatar/tiny/${a.did}/${p.avatar.ref.$link}/webp` 150 - : undefined, 151 - } as SoSprkActorDefs.ProfileViewBasic 152 - }) 150 + if (knownFollowsQuery.length > 0) { 151 + const knownFollowerDids = knownFollowsQuery.map((f) => f.subject) 152 + 153 + // Get profiles for known followers 154 + const knownFollowerProfiles = await ctx.db.models.Profile.find({ 155 + authorDid: { $in: knownFollowerDids }, 156 + }) 157 + .limit(3) 158 + .lean() 159 + 160 + const knownFollowersBasic = await Promise.all( 161 + knownFollowerProfiles.map(async (p) => { 162 + const handle = await ctx.resolver.resolveDidToHandle( 163 + p.authorDid, 164 + ) 165 + return { 166 + did: p.authorDid, 167 + handle, 168 + displayName: p.displayName, 169 + avatar: p.avatar 170 + ? `https://media.sprk.so/avatar/tiny/${p.authorDid}/${p.avatar.ref.$link}/webp` 171 + : undefined, 172 + } as SoSprkActorDefs.ProfileViewBasic 173 + }), 174 + ) 153 175 154 - viewer.knownFollowers = { 155 - count: knownFollowsQuery.length, 156 - followers: knownFollowersBasic, 176 + viewer.knownFollowers = { 177 + count: knownFollowsQuery.length, 178 + followers: knownFollowersBasic, 179 + } 180 + } 157 181 } 158 182 } 159 - } 160 - } 161 183 162 - // Build the ProfileViewDetailed response with required fields 163 - const profileView: SoSprkActorDefs.ProfileViewDetailed = { 164 - did: actorDid, 165 - handle: actor.handle || actorDid, 166 - viewer: viewerDid ? viewer : undefined, 167 - } 168 - 169 - // Only add optional fields if profile exists 170 - if (profile) { 171 - const associated: SoSprkActorDefs.ProfileAssociated = {} 184 + // Check for associated services 185 + const associated: SoSprkActorDefs.ProfileAssociated = {} 172 186 173 - // Check for feed generators 174 - let feedgensCount = 0 175 - try { 176 - if (ctx.db.models.Generator) { 177 - feedgensCount = await ctx.db.models.Generator.countDocuments({ 178 - authorDid: actorDid, 179 - }) 187 + // Check for feed generators 188 + let feedgensCount = 0 189 + try { 190 + if (ctx.db.models.Generator) { 191 + feedgensCount = await ctx.db.models.Generator.countDocuments({ 192 + authorDid: actorDid, 193 + }) 194 + } 195 + } catch (error) { 196 + // Ignore if model doesn't exist 180 197 } 181 - } catch (error) { 182 - // Ignore if model doesn't exist 183 - } 184 198 185 - if (feedgensCount > 0) { 186 - associated.feedgens = feedgensCount 187 - } 199 + if (feedgensCount > 0) { 200 + associated.feedgens = feedgensCount 201 + } 188 202 189 - Object.assign(profileView, { 190 - displayName: profile.displayName, 191 - description: profile.description, 192 - avatar: profile.avatar 203 + // Get avatar and banner URLs 204 + const avatar = profile.avatar 193 205 ? `https://media.sprk.so/avatar/tiny/${actorDid}/${profile.avatar.ref.$link}/webp` 194 - : undefined, 195 - banner: profile.banner 206 + : undefined 207 + const banner = profile.banner 196 208 ? `https://media.sprk.so/img/tiny/${actorDid}/${profile.banner.ref.$link}/webp` 197 - : undefined, 198 - followersCount: actor.followersCount, 199 - followsCount: actor.followingCount, 200 - postsCount: actor.postsCount, 201 - associated: Object.keys(associated).length > 0 ? associated : undefined, 202 - joinedViaStarterPack: profile.joinedViaStarterPack as unknown as SoSprkGraphDefs.StarterPackViewBasic, 203 - indexedAt: profile.indexedAt, 204 - createdAt: profile.createdAt, 205 - labels: Array.isArray(profile.labels) ? profile.labels as Label[] : undefined, 206 - pinnedPost: profile.pinnedPost as unknown as ComAtprotoRepoStrongRef.Main, 207 - }) 208 - } 209 + : undefined 210 + 211 + // Convert joinedViaStarterPack to the correct type if it exists 212 + let joinedViaStarterPack: 213 + | SoSprkGraphDefs.StarterPackViewBasic 214 + | undefined = undefined 215 + if (profile.joinedViaStarterPack) { 216 + // Type assertion assuming the structure fits the requirements 217 + joinedViaStarterPack = 218 + profile.joinedViaStarterPack as unknown as SoSprkGraphDefs.StarterPackViewBasic 219 + } 220 + 221 + // Convert labels to the correct type if it exists 222 + let labels: Label[] | undefined = undefined 223 + if (profile.labels) { 224 + labels = Array.isArray(profile.labels) 225 + ? (profile.labels as Label[]) 226 + : undefined 227 + } 228 + 229 + // Convert pinnedPost to the correct type if it exists 230 + let pinnedPost: ComAtprotoRepoStrongRef.Main | undefined = undefined 231 + if (profile.pinnedPost) { 232 + pinnedPost = 233 + profile.pinnedPost as unknown as ComAtprotoRepoStrongRef.Main 234 + } 235 + 236 + // Build the ProfileViewDetailed response 237 + const profileView: SoSprkActorDefs.ProfileViewDetailed = { 238 + did: actorDid, 239 + handle: profileHandle, 240 + displayName: profile.displayName, 241 + description: profile.description, 242 + avatar, 243 + banner, 244 + followersCount, 245 + followsCount, 246 + postsCount, 247 + associated: Object.keys(associated).length > 0 ? associated : undefined, 248 + joinedViaStarterPack, 249 + indexedAt: profile.indexedAt, 250 + createdAt: profile.createdAt, 251 + viewer: Object.keys(viewer).length > 0 ? viewer : undefined, 252 + labels, 253 + pinnedPost, 254 + } 255 + 256 + return c.json(profileView) 257 + }, 258 + ) 209 259 210 - return c.json(profileView) 260 + return router 211 261 }
+26 -56
services/appview/src/routes/so/sprk/actor/searchActor.ts
··· 29 29 } 30 30 } 31 31 32 - // Build the filter for actors instead of directly searching profiles 33 - const actorFilter: any = {} 32 + const filter: any = {} 34 33 const sort: any = {} 35 - 36 - // Only search for actors that already have profiles 37 - actorFilter.profile = { $exists: true, $ne: null } 38 34 39 35 if (q) { 40 36 const escaped = escapeRegExp(q) 41 37 const regex = new RegExp(escaped, 'i') 42 - 43 - // Search by handle directly on actor model 44 - actorFilter.$or = [ 45 - { handle: regex } 38 + filter.$or = [ 39 + { displayName: regex }, 40 + { description: regex }, 41 + { handle: regex }, 46 42 ] 47 - 48 - // For queries matching profile fields, we need to find actors by their profiles 49 - const profileIds = await ctx.db.models.Profile.find({ 50 - $or: [ 51 - { displayName: regex }, 52 - { description: regex } 53 - ] 54 - }) 55 - .select('_id authorDid') 56 - .lean() 57 - 58 - // Add actor DIDs from matching profiles 59 - if (profileIds.length > 0) { 60 - const profileDids = profileIds.map(p => p.authorDid) 61 - // Add to $or condition 62 - actorFilter.$or.push({ did: { $in: profileDids } }) 63 - } 64 - 65 - // Sort by recency and relevance 66 - sort.indexedAt = -1 43 + // fall back to sorting by createdAt 44 + sort.createdAt = -1 67 45 } else { 68 - // Default sort for discovery - prioritize recently indexed actors 69 - sort.indexedAt = -1 46 + sort.createdAt = -1 70 47 } 71 48 72 - // Find actors with populated profiles - no need to index them 73 - const actorsWithProfiles = await ctx.db.models.Actor.find(actorFilter) 74 - .populate('profile') 49 + const profiles = await ctx.db.models.Profile.find(filter) 75 50 .sort(sort) 76 51 .skip(skip) 77 52 .limit(limit) 78 53 .lean() 79 54 80 - // Filter out any invalid profiles and transform to profile views 81 - const actors: SoSprkActorDefs.ProfileView[] = actorsWithProfiles 82 - .filter(actor => actor.profile) 83 - .map(actor => { 84 - const profile = actor.profile as any 85 - 86 - const avatar = profile?.avatar 87 - ? `https://media.sprk.so/avatar/tiny/${actor.did}/${profile.avatar.ref.$link}/webp` 55 + const actors: SoSprkActorDefs.ProfileView[] = await Promise.all( 56 + profiles.map(async (p) => { 57 + const avatar = p.avatar 58 + ? `https://media.sprk.so/avatar/tiny/${p.authorDid}/${(p.avatar as any).ref.$link}/webp` 88 59 : undefined 89 - 90 - const labels = profile?.labels && Array.isArray(profile.labels) 91 - ? (profile.labels as Label[]) 60 + const labels = Array.isArray(p.labels) 61 + ? (p.labels as Label[]) 92 62 : undefined 93 - 63 + const handle = await ctx.resolver.resolveDidToHandle(p.authorDid) 94 64 return { 95 65 $type: 'so.sprk.actor.defs#profileView', 96 - did: actor.did, 97 - handle: actor.handle || actor.did, 98 - displayName: profile?.displayName, 99 - description: profile?.description, 66 + did: p.authorDid, 67 + handle: handle, 68 + displayName: p.displayName, 69 + description: p.description, 100 70 avatar, 101 - indexedAt: actor.indexedAt, 102 - createdAt: actor.createdAt ? new Date(actor.createdAt).toISOString() : undefined, 71 + indexedAt: p.indexedAt, 72 + createdAt: p.createdAt, 103 73 labels, 104 74 } satisfies SoSprkActorDefs.ProfileView 105 - }) 75 + }), 76 + ) 106 77 107 - // Calculate cursor for pagination 108 78 const nextCursor = 109 - actorsWithProfiles.length === limit ? String(skip + limit) : undefined 79 + profiles.length === limit ? String(skip + limit) : undefined 110 80 const result: SoSprkActorSearch.OutputSchema = { actors } 111 81 if (nextCursor) { 112 82 result.cursor = nextCursor ··· 116 86 }) 117 87 118 88 return router 119 - } 89 + }
+15 -2
services/appview/src/services/indexing.ts
··· 122 122 const actor = await this.db.models.Actor.findOne({ did }) 123 123 124 124 // Skip if recently indexed and not forced 125 - if (!force && actor && this.isHandleRecentlyIndexed(actor, timestamp)) { 125 + if (!force && actor && this.isHandleRecentlyIndexed(actor, timestamp)) { 126 126 return 127 127 } 128 128 ··· 148 148 } 149 149 } 150 150 151 + const existingProfile = await this.db.models.Profile.findOne({ authorDid: did }) 152 + if (existingProfile) { 153 + console.log('existingProfile: ', existingProfile) 154 + } 155 + 151 156 // Update or create actor 152 157 await this.db.models.Actor.updateOne( 153 158 { did }, 154 159 { 155 160 $set: { 156 161 handle, 157 - indexedAt: timestamp 162 + indexedAt: timestamp, 163 + ...(existingProfile && existingProfile._id ? { 164 + profile: existingProfile._id, 165 + profileCid: existingProfile.cid 166 + } : {}) 158 167 }, 159 168 $setOnInsert: { 160 169 uri: `at://${did}/app.bsky.actor.profile`, ··· 167 176 }, 168 177 { upsert: true } 169 178 ) 179 + 180 + if (existingProfile) { 181 + this.logger.info({ did, profileId: existingProfile._id }, 'Linked existing profile to actor during indexing') 182 + } 170 183 } catch (error) { 171 184 this.logger.error({ error, did }, 'Error indexing handle') 172 185 }
+15 -16
services/ingester/src/handlers/actor-handler.ts
··· 1 1 import { pino } from 'pino' 2 2 import { Database } from '../db/connection.js' 3 3 import type { NormalizedEvent } from '../types/events.js' 4 - import { ensureActor } from '../utils/actor-utils.js' 4 + import { IndexingService } from '../services/indexing.js' 5 + import { BidirectionalResolver } from '../id-resolver.js' 5 6 6 7 const logger = pino({ name: 'actor-handler' }) 7 8 ··· 14 15 */ 15 16 export async function handleActorReferences(evt: NormalizedEvent, db: Database): Promise<void> { 16 17 try { 18 + const now = new Date().toISOString() 19 + const resolver = new BidirectionalResolver() 20 + const indexingService = new IndexingService(db, resolver) 21 + 17 22 // Always ensure the author DID has an actor 18 23 if (evt.did) { 19 - await ensureActor(evt.did, evt.handle || undefined, db) 24 + await indexingService.indexHandle(evt.did, now) 20 25 } 21 26 22 27 // Handle subject DIDs for follow, block, like events ··· 24 29 // Subject is usually a DID in format did:plc:12345 25 30 const subjectDid = evt.record.subject as string 26 31 if (subjectDid && subjectDid.startsWith('did:')) { 27 - await ensureActor(subjectDid, undefined, db) 32 + await indexingService.indexHandle(subjectDid, now) 28 33 } 29 34 } 30 35 ··· 36 41 if (reply.root?.uri) { 37 42 const rootDid = extractDidFromUri(reply.root.uri) 38 43 if (rootDid) { 39 - await ensureActor(rootDid, undefined, db) 44 + await indexingService.indexHandle(rootDid, now) 40 45 } 41 46 } 42 47 43 48 if (reply.parent?.uri) { 44 49 const parentDid = extractDidFromUri(reply.parent.uri) 45 50 if (parentDid && parentDid !== extractDidFromUri(reply.root?.uri || '')) { 46 - await ensureActor(parentDid, undefined, db) 51 + await indexingService.indexHandle(parentDid, now) 47 52 } 48 53 } 49 54 } ··· 53 58 const subjectUri = evt.record.subject.uri as string 54 59 const subjectDid = extractDidFromUri(subjectUri) 55 60 if (subjectDid) { 56 - await ensureActor(subjectDid, undefined, db) 61 + await indexingService.indexHandle(subjectDid, now) 57 62 } 58 63 } 59 64 } catch (error) { ··· 62 67 } 63 68 64 69 /** 65 - * Extracts a DID from an AT URI (at://did:plc:12345/...) 66 - * 67 - * @param uri The URI to extract the DID from 68 - * @returns The extracted DID or undefined 70 + * Helper function to extract DID from a URI 69 71 */ 70 - function extractDidFromUri(uri: string): string | undefined { 71 - if (!uri) return undefined 72 - 73 - // Match a DID in an AT URI format 74 - const match = uri.match(/at:\/\/(did:[a-zA-Z0-9:]+)\//) 75 - return match ? match[1] : undefined 72 + function extractDidFromUri(uri: string): string | null { 73 + const match = uri.match(/at:\/\/(did:[^/]+)/) 74 + return match ? match[1] : null 76 75 }
+61
services/ingester/src/id-resolver.ts
··· 1 + import { pino } from 'pino' 2 + 3 + const logger = pino({ name: 'id-resolver' }) 4 + 5 + export interface DidDocument { 6 + did: string 7 + handle?: string 8 + } 9 + 10 + /** 11 + * Service to handle resolving DIDs to handles and vice versa 12 + */ 13 + export class BidirectionalResolver { 14 + private logger = pino({ name: 'id-resolver' }) 15 + 16 + /** 17 + * Resolve a DID to its DID document 18 + */ 19 + async resolveDidToDidDoc(did: string): Promise<DidDocument> { 20 + try { 21 + // TODO: Implement actual DID resolution 22 + // For now, return basic document 23 + return { 24 + did, 25 + } 26 + } catch (error) { 27 + this.logger.error({ error, did }, 'Failed to resolve DID to DID document') 28 + throw error 29 + } 30 + } 31 + 32 + /** 33 + * Resolve a handle to its DID document 34 + */ 35 + async resolveHandleToDidDoc(handle: string): Promise<DidDocument> { 36 + try { 37 + // TODO: Implement actual handle resolution 38 + // For now, return basic document 39 + return { 40 + did: `did:plc:${handle.toLowerCase()}`, 41 + handle, 42 + } 43 + } catch (error) { 44 + this.logger.error({ error, handle }, 'Failed to resolve handle to DID document') 45 + throw error 46 + } 47 + } 48 + 49 + /** 50 + * Resolve a DID to its handle 51 + */ 52 + async resolveDidToHandle(did: string): Promise<string> { 53 + try { 54 + const doc = await this.resolveDidToDidDoc(did) 55 + return doc.handle || did 56 + } catch (error) { 57 + this.logger.error({ error, did }, 'Failed to resolve DID to handle') 58 + throw error 59 + } 60 + } 61 + }
+109
services/ingester/src/services/indexing.ts
··· 1 + import { pino } from 'pino' 2 + import { Database } from '../db/connection.js' 3 + import { BidirectionalResolver } from '../id-resolver.js' 4 + 5 + const logger = pino({ name: 'indexing-service' }) 6 + 7 + /** 8 + * Service to handle indexing of actors and their handles 9 + */ 10 + export class IndexingService { 11 + private logger = pino({ name: 'indexing-service' }) 12 + 13 + constructor( 14 + private db: Database, 15 + private resolver: BidirectionalResolver, 16 + ) {} 17 + 18 + /** 19 + * Index or update actor handle information 20 + * 21 + * @param did The DID of the actor 22 + * @param timestamp The timestamp of the operation 23 + * @param force Force reindexing even if recently indexed 24 + */ 25 + async indexHandle(did: string, timestamp: string, force = false): Promise<void> { 26 + try { 27 + // Find existing actor 28 + const actor = await this.db.models.Actor.findOne({ did }) 29 + 30 + // Skip if recently indexed and not forced 31 + if (!force && actor && this.isHandleRecentlyIndexed(actor, timestamp)) { 32 + return 33 + } 34 + 35 + // Resolve DID to handle 36 + const didDoc = await this.resolver.resolveDidToDidDoc(did) 37 + 38 + // Verify handle ownership 39 + let handle: string | undefined = undefined 40 + if (didDoc.handle) { 41 + const handleDidDoc = await this.resolver.resolveHandleToDidDoc(didDoc.handle) 42 + handle = did === handleDidDoc.did ? didDoc.handle.toLowerCase() : undefined 43 + } 44 + 45 + // Handle conflict resolution - if another actor has this handle 46 + if (handle) { 47 + const actorWithHandle = await this.db.models.Actor.findOne({ handle }) 48 + if (actorWithHandle && actorWithHandle.did !== did) { 49 + // Clear handle from the other actor 50 + await this.db.models.Actor.updateOne( 51 + { did: actorWithHandle.did }, 52 + { $set: { handle: null } } 53 + ) 54 + } 55 + } 56 + 57 + const existingProfile = await this.db.models.Profile.findOne({ authorDid: did }) 58 + 59 + // Update or create actor 60 + await this.db.models.Actor.updateOne( 61 + { did }, 62 + { 63 + $set: { 64 + handle, 65 + indexedAt: timestamp, 66 + ...(existingProfile && existingProfile._id ? { 67 + profile: existingProfile._id, 68 + profileCid: existingProfile.cid 69 + } : {}) 70 + }, 71 + $setOnInsert: { 72 + uri: `at://${did}/app.bsky.actor.profile`, 73 + followersCount: 0, 74 + followingCount: 0, 75 + postsCount: 0, 76 + isLabeler: false, 77 + priorityNotifications: false, 78 + } 79 + }, 80 + { upsert: true } 81 + ) 82 + 83 + if (existingProfile) { 84 + this.logger.info({ did, profileId: existingProfile._id }, 'Linked existing profile to actor during indexing') 85 + } 86 + } catch (error) { 87 + this.logger.error({ error, did }, 'Error indexing handle') 88 + } 89 + } 90 + 91 + /** 92 + * Check if an actor's handle was recently indexed 93 + */ 94 + private isHandleRecentlyIndexed(actor: any, timestamp: string): boolean { 95 + if (!actor.indexedAt) return false 96 + 97 + const timeDiff = new Date(timestamp).getTime() - new Date(actor.indexedAt).getTime() 98 + const ONE_DAY = 24 * 60 * 60 * 1000 99 + const ONE_HOUR = 60 * 60 * 1000 100 + 101 + // Reindex daily for all actors 102 + if (timeDiff > ONE_DAY) return false 103 + 104 + // Reindex more frequently for actors without handles 105 + if (actor.handle === null && timeDiff > ONE_HOUR) return false 106 + 107 + return true 108 + } 109 + }