Remote follow fix

This commit is contained in:
Christopher
2026-01-22 15:42:15 -08:00
parent 2906063411
commit 898125a131
5 changed files with 257 additions and 11 deletions
+148 -4
View File
@@ -1,19 +1,115 @@
import { NextResponse } from 'next/server';
import { db, posts, users } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
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;
};
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;
};
const fetchOutboxItems = async (outboxUrl: string, limit: number) => {
const res = await fetch(outboxUrl, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (!res.ok) return [];
const data = await res.json();
const first = data?.first;
if (first) {
if (typeof first === 'string') {
const pageRes = await fetch(first, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (!pageRes.ok) return [];
const page = await pageRes.json();
return page?.orderedItems || page?.items || [];
}
return first?.orderedItems || first?.items || [];
}
const items = data?.orderedItems || data?.items || [];
return items.slice(0, limit);
};
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const remote = parseRemoteHandle(handle);
// Return empty if no database
if (!db) {
return NextResponse.json({ posts: [], nextCursor: null });
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile?.outbox) {
return NextResponse.json({ posts: [] });
}
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
const author = {
id: remoteProfile.id || `remote:${authorHandle}`,
handle: authorHandle,
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
bio: sanitizeText(remoteProfile.summary),
};
const posts = outboxItems
.map((item: any) => {
const activity = item?.type === 'Create' ? item : null;
const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') {
return null;
}
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
return {
id: object.id || activity.id,
content: sanitizeText(object.content) || '',
createdAt: object.published || activity.published || new Date().toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author,
media: attachments
.filter((attachment: any) => attachment?.url)
.map((attachment: any, index: number) => ({
id: `${object.id || activity.id || 'media'}-${index}`,
url: attachment.url,
altText: sanitizeText(attachment.name) || null,
})),
};
})
.filter(Boolean);
return NextResponse.json({ posts, nextCursor: null });
}
// Find the user
@@ -22,7 +118,55 @@ export async function GET(request: Request, context: RouteContext) {
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile?.outbox) {
return NextResponse.json({ posts: [] });
}
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
const author = {
id: remoteProfile.id || `remote:${authorHandle}`,
handle: authorHandle,
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
bio: sanitizeText(remoteProfile.summary),
};
const posts = outboxItems
.map((item: any) => {
const activity = item?.type === 'Create' ? item : null;
const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') {
return null;
}
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
return {
id: object.id || activity.id,
content: sanitizeText(object.content) || '',
createdAt: object.published || activity.published || new Date().toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author,
media: attachments
.filter((attachment: any) => attachment?.url)
.map((attachment: any, index: number) => ({
id: `${object.id || activity.id || 'media'}-${index}`,
url: attachment.url,
altText: sanitizeText(attachment.name) || null,
})),
};
})
.filter(Boolean);
return NextResponse.json({
posts,
nextCursor: null,
});
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });