From b2d594d212430d7446caf7b38eb8af0853f44afc Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 22 Jan 2026 15:26:39 -0800 Subject: [PATCH] Fixed federation search --- src/app/api/search/route.ts | 65 ++++++++++++++++++++++++++- src/app/explore/page.tsx | 14 ++++-- src/app/page.tsx | 8 +--- src/app/search/page.tsx | 16 +++++-- src/lib/activitypub/fetch.ts | 75 ++++++++++++++++++++++++++++++++ src/lib/activitypub/webfinger.ts | 9 ++-- 6 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 src/lib/activitypub/fetch.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 654a199..2618974 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,6 +1,57 @@ import { NextResponse } from 'next/server'; import { db, users, posts } from '@/db'; import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm'; +import { resolveRemoteUser } from '@/lib/activitypub/fetch'; + +type SearchUser = { + id: string; + handle: string; + displayName: string | null; + avatarUrl: string | null; + bio: string | null; + profileUrl?: string | null; + isRemote?: boolean; +}; + +const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => { + let trimmed = query.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('acct:')) { + trimmed = trimmed.slice(5); + } + const withoutPrefix = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed; + if (withoutPrefix.includes(' ')) return null; + const parts = withoutPrefix.split('@').filter(Boolean); + if (parts.length !== 2) return null; + const [handle, domain] = parts; + if (!handle || !domain) return null; + if (!domain.includes('.') && !domain.includes(':')) return null; + return { handle: handle.toLowerCase(), domain: domain.toLowerCase() }; +}; + +const buildRemoteUser = ( + profile: Awaited>, + handle: string, + domain: string, +): SearchUser | null => { + if (!profile) return null; + const displayName = profile.name || profile.preferredUsername || null; + const username = profile.preferredUsername || handle; + if (!username) return null; + const fullHandle = `${username}@${domain}`.replace(/^@/, ''); + const iconUrl = typeof profile.icon === 'string' ? profile.icon : profile.icon?.url; + const profileUrl = typeof profile.url === 'string' ? profile.url : profile.id; + + return { + id: profile.id || profileUrl || `remote:${fullHandle}`, + handle: fullHandle, + displayName, + avatarUrl: iconUrl ?? null, + bio: profile.summary ?? null, + profileUrl: profileUrl ?? null, + isRemote: true, + }; +}; export async function GET(request: Request) { try { @@ -23,7 +74,7 @@ export async function GET(request: Request) { } const searchPattern = `%${query}%`; - let searchUsers: { id: string; handle: string; displayName: string | null; avatarUrl: string | null; bio: string | null }[] = []; + let searchUsers: SearchUser[] = []; let searchPosts: typeof posts.$inferSelect[] = []; // Search users @@ -49,6 +100,18 @@ export async function GET(request: Request) { .limit(limit); } + // Federated user lookup (exact handle@domain queries) + if ((type === 'all' || type === 'users') && searchUsers.length < limit) { + const parsedRemote = parseRemoteHandleQuery(query); + if (parsedRemote) { + const remoteProfile = await resolveRemoteUser(parsedRemote.handle, parsedRemote.domain); + const remoteUser = buildRemoteUser(remoteProfile, parsedRemote.handle, parsedRemote.domain); + if (remoteUser && !searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) { + searchUsers = [remoteUser, ...searchUsers].slice(0, limit); + } + } + } + const moderatedUsers = await db.select({ id: users.id }) .from(users) .where(or(eq(users.isSuspended, true), eq(users.isSilenced, true))); diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index b8a578e..0888867 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, type ElementType } from 'react'; import Link from 'next/link'; import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons'; @@ -10,6 +10,8 @@ interface User { displayName: string; avatarUrl?: string; bio?: string; + profileUrl?: string | null; + isRemote?: boolean; } interface MediaItem { @@ -106,8 +108,14 @@ function PostCard({ post }: { post: Post }) { } function UserCard({ user }: { user: User }) { + const isRemote = Boolean(user.isRemote && user.profileUrl); + const Wrapper: ElementType = isRemote ? 'a' : Link; + const wrapperProps = isRemote + ? { href: user.profileUrl || '#', target: '_blank', rel: 'noopener noreferrer' } + : { href: `/${user.handle}` }; + return ( - +
{user.avatarUrl ? ( {user.displayName} @@ -120,7 +128,7 @@ function UserCard({ user }: { user: User }) {
@{user.handle}
{user.bio &&
{user.bio}
}
- +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 73984fa..a55e837 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -136,13 +136,7 @@ export default function Home() {
Curated feed
- We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible. -
-
- Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}. -
-
- Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts. + This feed highlights fresh posts and active discussions, with a boost for people you follow. It is designed to surface what matters without hiding your own activity.
)} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index c601135..c9c326f 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, type ElementType } from 'react'; import Link from 'next/link'; import { useSearchParams, useRouter } from 'next/navigation'; @@ -10,6 +10,8 @@ interface User { displayName: string; avatarUrl?: string; bio?: string; + profileUrl?: string | null; + isRemote?: boolean; } interface MediaItem { @@ -73,9 +75,15 @@ const FlagIcon = () => ( ); function UserCard({ user }: { user: User }) { + const isRemote = Boolean(user.isRemote && user.profileUrl); + const Wrapper: ElementType = isRemote ? 'a' : Link; + const wrapperProps = isRemote + ? { href: user.profileUrl || '#', target: '_blank', rel: 'noopener noreferrer' } + : { href: `/@${user.handle}` }; + return ( - )} - + ); } diff --git a/src/lib/activitypub/fetch.ts b/src/lib/activitypub/fetch.ts new file mode 100644 index 0000000..54c2dc2 --- /dev/null +++ b/src/lib/activitypub/fetch.ts @@ -0,0 +1,75 @@ + +import { fetchWebFinger, getActorUrlFromWebFinger } from './webfinger'; + +export interface ActivityPubProfile { + id: string; + type: string; + preferredUsername: string; + name?: string; + summary?: string; + url?: string; + icon?: { + url: string; + } | string; + image?: { + url: string; + } | string; + publicKey?: { + id: string; + owner: string; + publicKeyPem: string; + }; +} + +/** + * Fetch a remote ActivityPub actor + */ +export async function fetchRemoteActor(url: string): Promise { + try { + const res = await fetch(url, { + headers: { + 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'User-Agent': 'Synapsis/1.0 (+https://synapsis.social)', + }, + }); + + if (!res.ok) { + console.error(`Failed to fetch actor: ${res.status} ${res.statusText}`); + return null; + } + + const data = await res.json(); + + // Basic validation + if (!data.id || !data.type) { + return null; + } + + return data as ActivityPubProfile; + } catch (error) { + console.error('Error fetching remote actor:', error); + return null; + } +} + +/** + * Resolve a remote user via WebFinger and fetch their profile + * @param handle The username (without domain) + * @param domain The domain name + */ +export async function resolveRemoteUser(handle: string, domain: string): Promise { + // 1. WebFinger lookup + const webfinger = await fetchWebFinger(handle, domain); + if (!webfinger) { + return null; + } + + // 2. Get Actor URL + const actorUrl = getActorUrlFromWebFinger(webfinger); + if (!actorUrl) { + return null; + } + + // 3. Fetch Actor Profile + return await fetchRemoteActor(actorUrl); +} diff --git a/src/lib/activitypub/webfinger.ts b/src/lib/activitypub/webfinger.ts index 478e6de..56eb6f7 100644 --- a/src/lib/activitypub/webfinger.ts +++ b/src/lib/activitypub/webfinger.ts @@ -106,8 +106,11 @@ export async function fetchWebFinger( * Get the ActivityPub actor URL from a WebFinger response */ export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null { - const selfLink = webfinger.links.find( - (link) => link.rel === 'self' && link.type === 'application/activity+json' - ); + const selfLink = webfinger.links.find((link) => { + if (link.rel !== 'self' || !link.href) return false; + if (!link.type) return true; + const type = link.type.toLowerCase(); + return type.includes('activity+json') || type.includes('activitystreams') || type.includes('application/ld+json'); + }); return selfLink?.href ?? null; }