Remote follow fix 2

This commit is contained in:
Christopher
2026-01-22 15:35:47 -08:00
parent b2d594d212
commit 2906063411
7 changed files with 125 additions and 21 deletions
+19 -2
View File
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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,
};
@@ -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 });
+45
View File
@@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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) {
+3 -9
View File
@@ -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 (
<Wrapper {...wrapperProps} className="user-card">
<Link href={`/${user.handle}`} className="user-card">
<div className="avatar">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} />
@@ -128,7 +122,7 @@ function UserCard({ user }: { user: User }) {
<div className="user-card-handle">@{user.handle}</div>
{user.bio && <div className="user-card-bio">{user.bio}</div>}
</div>
</Wrapper>
</Link>
);
}
+4 -10
View File
@@ -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 (
<Wrapper
{...wrapperProps}
<Link
href={`/@${user.handle}`}
style={{
display: 'flex',
alignItems: 'center',
@@ -117,7 +111,7 @@ function UserCard({ user }: { user: User }) {
</div>
)}
</div>
</Wrapper>
</Link>
);
}
+4
View File
@@ -8,6 +8,10 @@ export interface ActivityPubProfile {
name?: string;
summary?: string;
url?: string;
inbox?: string;
endpoints?: {
sharedInbox?: string;
};
icon?: {
url: string;
} | string;
+2
View File
@@ -11,6 +11,8 @@ export interface User {
website?: string | null;
createdAt?: string;
movedTo?: string | null;
isRemote?: boolean;
profileUrl?: string | null;
}
export interface MediaItem {