From 29060634119cbc949cb0824e445411b47fcf0edb Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 22 Jan 2026 15:35:47 -0800 Subject: [PATCH] Remote follow fix 2 --- src/app/api/search/route.ts | 21 +++++++++- src/app/api/users/[handle]/follow/route.ts | 48 ++++++++++++++++++++++ src/app/api/users/[handle]/route.ts | 45 ++++++++++++++++++++ src/app/explore/page.tsx | 12 ++---- src/app/search/page.tsx | 14 ++----- src/lib/activitypub/fetch.ts | 4 ++ src/lib/types.ts | 2 + 7 files changed, 125 insertions(+), 21 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 2618974..0ba66e5 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -13,6 +13,23 @@ type SearchUser = { isRemote?: boolean; }; +const decodeEntities = (value: string) => + value + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/&#(\\d+);/g, (_, num) => String.fromCharCode(Number(num))) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + +const sanitizeText = (value?: string | null) => { + if (!value) return null; + const withoutTags = value.replace(/<[^>]*>/g, ' '); + const decoded = decodeEntities(withoutTags); + return decoded.replace(/\\s+/g, ' ').trim() || null; +}; + const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => { let trimmed = query.trim(); if (!trimmed) return null; @@ -35,7 +52,7 @@ const buildRemoteUser = ( domain: string, ): SearchUser | null => { if (!profile) return null; - const displayName = profile.name || profile.preferredUsername || null; + const displayName = sanitizeText(profile.name) || sanitizeText(profile.preferredUsername) || null; const username = profile.preferredUsername || handle; if (!username) return null; const fullHandle = `${username}@${domain}`.replace(/^@/, ''); @@ -47,7 +64,7 @@ const buildRemoteUser = ( handle: fullHandle, displayName, avatarUrl: iconUrl ?? null, - bio: profile.summary ?? null, + bio: sanitizeText(profile.summary), profileUrl: profileUrl ?? null, isRemote: true, }; diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index 683f285..818c38a 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -1,21 +1,39 @@ import { NextResponse } from 'next/server'; +import crypto from 'crypto'; import { db, follows, users, notifications } from '@/db'; import { eq, and } from 'drizzle-orm'; import { requireAuth } from '@/lib/auth'; +import { resolveRemoteUser } from '@/lib/activitypub/fetch'; +import { createFollowActivity } from '@/lib/activitypub/activities'; +import { deliverActivity } from '@/lib/activitypub/outbox'; type RouteContext = { params: Promise<{ handle: string }> }; +const parseRemoteHandle = (handle: string) => { + const clean = handle.toLowerCase().replace(/^@/, ''); + const parts = clean.split('@').filter(Boolean); + if (parts.length === 2) { + return { handle: parts[0], domain: parts[1] }; + } + return null; +}; + // Check follow status export async function GET(request: Request, context: RouteContext) { try { const currentUser = await requireAuth(); const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); + const remote = parseRemoteHandle(handle); if (currentUser.isSuspended || currentUser.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); } + if (remote) { + return NextResponse.json({ following: false, remote: true }); + } + if (!db) { return NextResponse.json({ error: 'Database not available' }, { status: 503 }); } @@ -58,11 +76,36 @@ export async function POST(request: Request, context: RouteContext) { const currentUser = await requireAuth(); const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); + const remote = parseRemoteHandle(handle); if (currentUser.isSuspended || currentUser.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); } + if (remote) { + const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); + if (!remoteProfile) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox; + if (!targetInbox) { + return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 }); + } + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const activityId = crypto.randomUUID(); + const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId); + const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`; + const privateKey = currentUser.privateKeyEncrypted; + if (!privateKey) { + return NextResponse.json({ error: 'Missing signing key' }, { status: 500 }); + } + const result = await deliverActivity(followActivity, targetInbox, privateKey, keyId); + if (!result.success) { + return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 }); + } + return NextResponse.json({ success: true, following: true, remote: true }); + } + if (!db) { return NextResponse.json({ error: 'Database not available' }, { status: 503 }); } @@ -137,6 +180,11 @@ export async function DELETE(request: Request, context: RouteContext) { const currentUser = await requireAuth(); const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); + const remote = parseRemoteHandle(handle); + + if (remote) { + return NextResponse.json({ error: 'Unfollow for remote users not supported yet' }, { status: 501 }); + } if (!db) { return NextResponse.json({ error: 'Database not available' }, { status: 503 }); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 4ad45f1..bcc1972 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -2,13 +2,32 @@ import { NextResponse } from 'next/server'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; import { userToActor } from '@/lib/activitypub/actor'; +import { resolveRemoteUser } from '@/lib/activitypub/fetch'; type RouteContext = { params: Promise<{ handle: string }> }; +const decodeEntities = (value: string) => + value + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num))) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + +const sanitizeText = (value?: string | null) => { + if (!value) return null; + const withoutTags = value.replace(/<[^>]*>/g, ' '); + const decoded = decodeEntities(withoutTags); + return decoded.replace(/\s+/g, ' ').trim() || null; +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); + const [remoteHandle, remoteDomain] = cleanHandle.split('@'); // Return mock user if no database if (!db) { @@ -33,6 +52,32 @@ export async function GET(request: Request, context: RouteContext) { }); if (!user) { + if (remoteHandle && remoteDomain) { + const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain); + if (remoteProfile) { + const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle; + const iconUrl = typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url; + const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url; + const profileUrl = typeof remoteProfile.url === 'string' ? remoteProfile.url : remoteProfile.id; + return NextResponse.json({ + user: { + id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`, + handle: `${remoteProfile.preferredUsername || remoteHandle}@${remoteDomain}`, + displayName, + bio: sanitizeText(remoteProfile.summary), + avatarUrl: iconUrl ?? null, + headerUrl: headerUrl ?? null, + followersCount: 0, + followingCount: 0, + postsCount: 0, + website: profileUrl ?? null, + createdAt: new Date().toISOString(), + isRemote: true, + profileUrl: profileUrl ?? null, + } + }); + } + } return NextResponse.json({ error: 'User not found' }, { status: 404 }); } if (user.isSuspended) { diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 0888867..59ca59e 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, type ElementType } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons'; @@ -108,14 +108,8 @@ 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} @@ -128,7 +122,7 @@ function UserCard({ user }: { user: User }) {
@{user.handle}
{user.bio &&
{user.bio}
}
-
+ ); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index c9c326f..ccef5b8 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, type ElementType } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; import { useSearchParams, useRouter } from 'next/navigation'; @@ -75,15 +75,9 @@ 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 index 54c2dc2..a15dad1 100644 --- a/src/lib/activitypub/fetch.ts +++ b/src/lib/activitypub/fetch.ts @@ -8,6 +8,10 @@ export interface ActivityPubProfile { name?: string; summary?: string; url?: string; + inbox?: string; + endpoints?: { + sharedInbox?: string; + }; icon?: { url: string; } | string; diff --git a/src/lib/types.ts b/src/lib/types.ts index c76a665..c53aba7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -11,6 +11,8 @@ export interface User { website?: string | null; createdAt?: string; movedTo?: string | null; + isRemote?: boolean; + profileUrl?: string | null; } export interface MediaItem {