Remote follow fix
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import crypto from 'crypto';
|
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 { eq, and } from 'drizzle-orm';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
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';
|
import { deliverActivity } from '@/lib/activitypub/outbox';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
@@ -31,7 +31,17 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (remote) {
|
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) {
|
if (!db) {
|
||||||
@@ -92,6 +102,18 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
|
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
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 activityId = crypto.randomUUID();
|
||||||
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
|
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
|
||||||
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
||||||
@@ -103,6 +125,13 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
|
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 });
|
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);
|
const remote = parseRemoteHandle(handle);
|
||||||
|
|
||||||
if (remote) {
|
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) {
|
if (!db) {
|
||||||
|
|||||||
@@ -1,19 +1,115 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, posts, users } from '@/db';
|
import { db, posts, users } from '@/db';
|
||||||
import { eq, desc, and } from 'drizzle-orm';
|
import { eq, desc, and } from 'drizzle-orm';
|
||||||
|
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
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) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
const { searchParams } = new URL(request.url);
|
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) {
|
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
|
// Find the user
|
||||||
@@ -22,7 +118,55 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
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) {
|
if (user.isSuspended) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
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;
|
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) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
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 iconUrl = typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url;
|
||||||
const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url;
|
const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url;
|
||||||
const profileUrl = typeof remoteProfile.url === 'string' ? remoteProfile.url : remoteProfile.id;
|
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({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`,
|
id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`,
|
||||||
@@ -67,9 +89,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
bio: sanitizeText(remoteProfile.summary),
|
bio: sanitizeText(remoteProfile.summary),
|
||||||
avatarUrl: iconUrl ?? null,
|
avatarUrl: iconUrl ?? null,
|
||||||
headerUrl: headerUrl ?? null,
|
headerUrl: headerUrl ?? null,
|
||||||
followersCount: 0,
|
followersCount,
|
||||||
followingCount: 0,
|
followingCount,
|
||||||
postsCount: 0,
|
postsCount,
|
||||||
website: profileUrl ?? null,
|
website: profileUrl ?? null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
isRemote: true,
|
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
|
// LIKES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface ActivityPubProfile {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
inbox?: string;
|
inbox?: string;
|
||||||
|
outbox?: string;
|
||||||
|
followers?: string;
|
||||||
|
following?: string;
|
||||||
endpoints?: {
|
endpoints?: {
|
||||||
sharedInbox?: string;
|
sharedInbox?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user