Remote follow fix
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(/&/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 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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface ActivityPubProfile {
|
||||
summary?: string;
|
||||
url?: string;
|
||||
inbox?: string;
|
||||
outbox?: string;
|
||||
followers?: string;
|
||||
following?: string;
|
||||
endpoints?: {
|
||||
sharedInbox?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user