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
@@ -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) {