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
+64 -4
View File
@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server';
import crypto from 'crypto';
import { db, follows, users, notifications } from '@/db';
import { db, follows, users, notifications, remoteFollows } 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 { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
import { deliverActivity } from '@/lib/activitypub/outbox';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -31,7 +31,17 @@ export async function GET(request: Request, context: RouteContext) {
}
if (remote) {
return NextResponse.json({ following: false, remote: true });
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
});
return NextResponse.json({ following: !!existingRemoteFollow, remote: true });
}
if (!db) {
@@ -92,6 +102,18 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
});
if (existingRemoteFollow) {
return NextResponse.json({ error: 'Already following' }, { status: 400 });
}
const activityId = crypto.randomUUID();
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
@@ -103,6 +125,13 @@ export async function POST(request: Request, context: RouteContext) {
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
}
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: remoteProfile.id,
inboxUrl: targetInbox,
activityId,
});
return NextResponse.json({ success: true, following: true, remote: true });
}
@@ -183,7 +212,38 @@ export async function DELETE(request: Request, context: RouteContext) {
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 });
}
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
});
if (!existingRemoteFollow) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const originalFollow = createFollowActivity(
currentUser,
existingRemoteFollow.targetActorUrl,
nodeDomain,
existingRemoteFollow.activityId
);
const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID());
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(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId);
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
}
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
return NextResponse.json({ success: true, following: false, remote: true });
}
if (!db) {
+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 });
+25 -3
View File
@@ -23,6 +23,23 @@ const sanitizeText = (value?: string | null) => {
return decoded.replace(/\s+/g, ' ').trim() || null;
};
const fetchCollectionCount = async (url?: string | null) => {
if (!url) return 0;
try {
const res = await fetch(url, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (!res.ok) return 0;
const data = await res.json();
if (typeof data?.totalItems === 'number') return data.totalItems;
} catch {
return 0;
}
return 0;
};
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
@@ -59,6 +76,11 @@ export async function GET(request: Request, context: RouteContext) {
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;
const [followersCount, followingCount, postsCount] = await Promise.all([
fetchCollectionCount(remoteProfile.followers),
fetchCollectionCount(remoteProfile.following),
fetchCollectionCount(remoteProfile.outbox),
]);
return NextResponse.json({
user: {
id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`,
@@ -67,9 +89,9 @@ export async function GET(request: Request, context: RouteContext) {
bio: sanitizeText(remoteProfile.summary),
avatarUrl: iconUrl ?? null,
headerUrl: headerUrl ?? null,
followersCount: 0,
followingCount: 0,
postsCount: 0,
followersCount,
followingCount,
postsCount,
website: profileUrl ?? null,
createdAt: new Date().toISOString(),
isRemote: true,
+17
View File
@@ -188,6 +188,23 @@ export const followsRelations = relations(follows, ({ one }) => ({
}),
}));
// ============================================
// REMOTE FOLLOWS (for federated follows)
// ============================================
export const remoteFollows = pgTable('remote_follows', {
id: uuid('id').primaryKey().defaultRandom(),
followerId: uuid('follower_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
targetHandle: text('target_handle').notNull(), // username@domain
targetActorUrl: text('target_actor_url').notNull(),
inboxUrl: text('inbox_url').notNull(),
activityId: text('activity_id').notNull(), // UUID token for activity URL
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('remote_follows_follower_idx').on(table.followerId),
index('remote_follows_target_idx').on(table.targetHandle),
]);
// ============================================
// LIKES
// ============================================
+3
View File
@@ -9,6 +9,9 @@ export interface ActivityPubProfile {
summary?: string;
url?: string;
inbox?: string;
outbox?: string;
followers?: string;
following?: string;
endpoints?: {
sharedInbox?: string;
};