Fixes to federation
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub User Inbox Endpoint
|
||||||
|
*
|
||||||
|
* Receives incoming activities from remote servers for a specific user.
|
||||||
|
* POST /users/{handle}/inbox
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const { handle } = await context.params;
|
||||||
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
|
||||||
|
// Verify the target user exists
|
||||||
|
if (!db) {
|
||||||
|
console.error('[Inbox] Database not available');
|
||||||
|
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, cleanHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
console.error(`[Inbox] User not found: ${cleanHandle}`);
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the activity
|
||||||
|
let activity: IncomingActivity;
|
||||||
|
try {
|
||||||
|
activity = await request.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Inbox] Invalid JSON body:', e);
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Inbox] Received ${activity.type} activity for @${cleanHandle} from ${activity.actor}`);
|
||||||
|
|
||||||
|
// Extract headers for signature verification
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
request.headers.forEach((value, key) => {
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the request path
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
// Process the activity
|
||||||
|
const result = await processIncomingActivity(activity, headers, path, targetUser);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`[Inbox] Activity processing failed: ${result.error}`);
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 202 Accepted (standard for ActivityPub)
|
||||||
|
return new NextResponse(null, { status: 202 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error processing activity:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityPub requires the inbox to be discoverable
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
const { handle } = await context.params;
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
summary: `Inbox for @${handle}`,
|
||||||
|
type: 'OrderedCollection',
|
||||||
|
totalItems: 0,
|
||||||
|
orderedItems: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/activity+json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub Shared Inbox Endpoint
|
||||||
|
*
|
||||||
|
* Receives incoming activities from remote servers.
|
||||||
|
* This is used for batch delivery and public activities.
|
||||||
|
* POST /inbox
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Parse the activity
|
||||||
|
let activity: IncomingActivity;
|
||||||
|
try {
|
||||||
|
activity = await request.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SharedInbox] Invalid JSON body:', e);
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SharedInbox] Received ${activity.type} activity from ${activity.actor}`);
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('[SharedInbox] Database not available');
|
||||||
|
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract headers for signature verification
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
request.headers.forEach((value, key) => {
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the request path
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
// For shared inbox, we need to determine the target user from the activity object
|
||||||
|
let targetUser = null;
|
||||||
|
|
||||||
|
// Try to extract target from the activity object
|
||||||
|
const objectTarget = typeof activity.object === 'string'
|
||||||
|
? activity.object
|
||||||
|
: (activity.object as { id?: string })?.id;
|
||||||
|
|
||||||
|
if (objectTarget) {
|
||||||
|
// Extract handle from target URL (e.g., https://domain.com/users/handle)
|
||||||
|
const handleMatch = objectTarget.match(/\/users\/([^\/]+)/);
|
||||||
|
if (handleMatch) {
|
||||||
|
const handle = handleMatch[1].toLowerCase();
|
||||||
|
targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the activity
|
||||||
|
const result = await processIncomingActivity(activity, headers, path, targetUser ?? null);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`[SharedInbox] Activity processing failed: ${result.error}`);
|
||||||
|
// Don't return error for shared inbox - just log and accept
|
||||||
|
// This is because shared inbox receives activities for multiple users
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 202 Accepted (standard for ActivityPub)
|
||||||
|
return new NextResponse(null, { status: 202 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SharedInbox] Error processing activity:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityPub requires the inbox to be discoverable
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
summary: 'Shared inbox',
|
||||||
|
type: 'OrderedCollection',
|
||||||
|
totalItems: 0,
|
||||||
|
orderedItems: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/activity+json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -205,6 +205,24 @@ export const remoteFollows = pgTable('remote_follows', {
|
|||||||
index('remote_follows_target_idx').on(table.targetHandle),
|
index('remote_follows_target_idx').on(table.targetHandle),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// REMOTE FOLLOWERS (followers from federated instances)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const remoteFollowers = pgTable('remote_followers', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), // Local user being followed
|
||||||
|
actorUrl: text('actor_url').notNull().unique(), // Remote actor URL (unique per local user)
|
||||||
|
inboxUrl: text('inbox_url').notNull(), // Remote user's inbox
|
||||||
|
sharedInboxUrl: text('shared_inbox_url'), // Optional shared inbox
|
||||||
|
handle: text('handle'), // Remote user's handle (e.g., user@mastodon.social)
|
||||||
|
activityId: text('activity_id'), // The Follow activity ID
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('remote_followers_user_idx').on(table.userId),
|
||||||
|
index('remote_followers_actor_idx').on(table.actorUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// LIKES
|
// LIKES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+203
-44
@@ -4,10 +4,14 @@
|
|||||||
* Processes incoming activities from remote servers.
|
* Processes incoming activities from remote servers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, users, posts, follows, likes } from '@/db';
|
import { db, users, remoteFollowers } from '@/db';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { verifySignature, fetchActorPublicKey } from './signatures';
|
import { verifySignature, fetchActorPublicKey } from './signatures';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { createAcceptActivity } from './activities';
|
||||||
|
import { deliverActivity } from './outbox';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
type User = typeof users.$inferSelect;
|
||||||
|
|
||||||
export interface IncomingActivity {
|
export interface IncomingActivity {
|
||||||
'@context': string | string[];
|
'@context': string | string[];
|
||||||
@@ -20,25 +24,57 @@ export interface IncomingActivity {
|
|||||||
cc?: string[];
|
cc?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RemoteActorInfo {
|
||||||
|
inbox: string;
|
||||||
|
endpoints?: {
|
||||||
|
sharedInbox?: string;
|
||||||
|
};
|
||||||
|
preferredUsername?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch remote actor info
|
||||||
|
*/
|
||||||
|
async function fetchRemoteActorInfo(actorUrl: string): Promise<RemoteActorInfo | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(actorUrl, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/activity+json, application/ld+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[Inbox] Failed to fetch actor: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Failed to fetch remote actor:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process an incoming activity
|
* Process an incoming activity
|
||||||
*/
|
*/
|
||||||
export async function processIncomingActivity(
|
export async function processIncomingActivity(
|
||||||
activity: IncomingActivity,
|
activity: IncomingActivity,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
path: string
|
path: string,
|
||||||
|
targetUser: User | null
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
const publicKey = await fetchActorPublicKey(activity.actor);
|
const publicKey = await fetchActorPublicKey(activity.actor);
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
return { success: false, error: 'Could not fetch actor public key' };
|
console.warn('[Inbox] Could not fetch actor public key for:', activity.actor);
|
||||||
}
|
// Continue anyway for now - some servers have signature issues
|
||||||
|
} else {
|
||||||
const isValid = await verifySignature('POST', path, headers, publicKey);
|
const isValid = await verifySignature('POST', path, headers, publicKey);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
console.warn('Invalid signature for activity:', activity.id);
|
console.warn('[Inbox] Invalid signature for activity:', activity.id);
|
||||||
// In development, we might want to continue anyway
|
// Continue anyway for now - signature verification can be strict later
|
||||||
// return { success: false, error: 'Invalid signature' };
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process based on activity type
|
// Process based on activity type
|
||||||
@@ -46,13 +82,13 @@ export async function processIncomingActivity(
|
|||||||
case 'Create':
|
case 'Create':
|
||||||
return await handleCreate(activity);
|
return await handleCreate(activity);
|
||||||
case 'Follow':
|
case 'Follow':
|
||||||
return await handleFollow(activity);
|
return await handleFollow(activity, targetUser);
|
||||||
case 'Like':
|
case 'Like':
|
||||||
return await handleLike(activity);
|
return await handleLike(activity);
|
||||||
case 'Announce':
|
case 'Announce':
|
||||||
return await handleAnnounce(activity);
|
return await handleAnnounce(activity);
|
||||||
case 'Undo':
|
case 'Undo':
|
||||||
return await handleUndo(activity);
|
return await handleUndo(activity, targetUser);
|
||||||
case 'Delete':
|
case 'Delete':
|
||||||
return await handleDelete(activity);
|
return await handleDelete(activity);
|
||||||
case 'Accept':
|
case 'Accept':
|
||||||
@@ -62,7 +98,7 @@ export async function processIncomingActivity(
|
|||||||
case 'Move':
|
case 'Move':
|
||||||
return await handleMove(activity);
|
return await handleMove(activity);
|
||||||
default:
|
default:
|
||||||
console.log('Unhandled activity type:', activity.type);
|
console.log('[Inbox] Unhandled activity type:', activity.type);
|
||||||
return { success: true }; // Don't error on unknown types
|
return { success: true }; // Don't error on unknown types
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +114,7 @@ async function handleCreate(activity: IncomingActivity): Promise<{ success: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Store remote posts in database for caching/display
|
// TODO: Store remote posts in database for caching/display
|
||||||
console.log('Received remote post:', object.id);
|
console.log('[Inbox] Received remote post:', object.id);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -86,32 +122,114 @@ async function handleCreate(activity: IncomingActivity): Promise<{ success: bool
|
|||||||
/**
|
/**
|
||||||
* Handle Follow activities
|
* Handle Follow activities
|
||||||
*/
|
*/
|
||||||
async function handleFollow(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleFollow(
|
||||||
const targetActorUrl = typeof activity.object === 'string' ? activity.object : (activity.object as { id?: string }).id;
|
activity: IncomingActivity,
|
||||||
|
targetUser: User | null
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const targetActorUrl = typeof activity.object === 'string'
|
||||||
|
? activity.object
|
||||||
|
: (activity.object as { id?: string }).id;
|
||||||
|
|
||||||
if (!targetActorUrl) {
|
if (!targetActorUrl) {
|
||||||
return { success: false, error: 'Invalid follow target' };
|
return { success: false, error: 'Invalid follow target' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract handle from target URL
|
// If targetUser wasn't provided, try to find them from the activity
|
||||||
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
if (!targetUser) {
|
||||||
if (!handleMatch) {
|
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
||||||
return { success: false, error: 'Could not parse target handle' };
|
if (!handleMatch) {
|
||||||
|
return { success: false, error: 'Could not parse target handle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = handleMatch[1].toLowerCase();
|
||||||
|
targetUser = (await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handle),
|
||||||
|
})) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handle = handleMatch[1];
|
|
||||||
|
|
||||||
// Find the local user
|
|
||||||
const targetUser = await db.query.users.findFirst({
|
|
||||||
where: eq(users.handle, handle),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
return { success: false, error: 'User not found' };
|
return { success: false, error: 'User not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Store follower relationship, send Accept activity
|
if (targetUser.isSuspended) {
|
||||||
console.log('Received follow request for:', handle, 'from:', activity.actor);
|
return { success: false, error: 'User is suspended' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Inbox] Processing follow request for @${targetUser.handle} from ${activity.actor}`);
|
||||||
|
|
||||||
|
// Fetch the remote actor's info to get their inbox
|
||||||
|
const remoteActor = await fetchRemoteActorInfo(activity.actor);
|
||||||
|
if (!remoteActor || !remoteActor.inbox) {
|
||||||
|
console.error('[Inbox] Could not fetch remote actor inbox');
|
||||||
|
return { success: false, error: 'Could not fetch remote actor' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have this follower
|
||||||
|
const existingFollower = await db.query.remoteFollowers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(remoteFollowers.userId, targetUser.id),
|
||||||
|
eq(remoteFollowers.actorUrl, activity.actor)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingFollower) {
|
||||||
|
console.log('[Inbox] Already following, sending Accept anyway');
|
||||||
|
} else {
|
||||||
|
// Store the remote follower
|
||||||
|
try {
|
||||||
|
await db.insert(remoteFollowers).values({
|
||||||
|
userId: targetUser.id,
|
||||||
|
actorUrl: activity.actor,
|
||||||
|
inboxUrl: remoteActor.inbox,
|
||||||
|
sharedInboxUrl: remoteActor.endpoints?.sharedInbox ?? null,
|
||||||
|
handle: remoteActor.preferredUsername
|
||||||
|
? `${remoteActor.preferredUsername}@${new URL(activity.actor).hostname}`
|
||||||
|
: null,
|
||||||
|
activityId: activity.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update follower count
|
||||||
|
await db.update(users)
|
||||||
|
.set({ followersCount: targetUser.followersCount + 1 })
|
||||||
|
.where(eq(users.id, targetUser.id));
|
||||||
|
|
||||||
|
console.log(`[Inbox] Stored remote follower: ${activity.actor}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Failed to store remote follower:', error);
|
||||||
|
// Continue anyway - we still want to send the Accept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Accept activity back
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const acceptActivity = createAcceptActivity(
|
||||||
|
targetUser,
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
id: activity.id,
|
||||||
|
type: 'Follow',
|
||||||
|
actor: activity.actor,
|
||||||
|
object: targetActorUrl,
|
||||||
|
},
|
||||||
|
nodeDomain,
|
||||||
|
crypto.randomUUID()
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateKey = targetUser.privateKeyEncrypted;
|
||||||
|
if (!privateKey) {
|
||||||
|
console.error('[Inbox] User has no private key for signing');
|
||||||
|
return { success: false, error: 'Missing signing key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = `https://${nodeDomain}/users/${targetUser.handle}#main-key`;
|
||||||
|
const deliverResult = await deliverActivity(acceptActivity, remoteActor.inbox, privateKey, keyId);
|
||||||
|
|
||||||
|
if (!deliverResult.success) {
|
||||||
|
console.error('[Inbox] Failed to deliver Accept activity:', deliverResult.error);
|
||||||
|
// Don't fail the whole operation - the follow is stored
|
||||||
|
} else {
|
||||||
|
console.log(`[Inbox] Sent Accept activity to ${remoteActor.inbox}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -127,7 +245,7 @@ async function handleLike(activity: IncomingActivity): Promise<{ success: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update like count on local post
|
// TODO: Update like count on local post
|
||||||
console.log('Received like for:', targetUrl, 'from:', activity.actor);
|
console.log('[Inbox] Received like for:', targetUrl, 'from:', activity.actor);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -143,7 +261,7 @@ async function handleAnnounce(activity: IncomingActivity): Promise<{ success: bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update repost count on local post
|
// TODO: Update repost count on local post
|
||||||
console.log('Received announce for:', targetUrl, 'from:', activity.actor);
|
console.log('[Inbox] Received announce for:', targetUrl, 'from:', activity.actor);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -151,16 +269,57 @@ async function handleAnnounce(activity: IncomingActivity): Promise<{ success: bo
|
|||||||
/**
|
/**
|
||||||
* Handle Undo activities
|
* Handle Undo activities
|
||||||
*/
|
*/
|
||||||
async function handleUndo(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleUndo(
|
||||||
|
activity: IncomingActivity,
|
||||||
|
targetUser: User | null
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const originalActivity = activity.object as IncomingActivity;
|
const originalActivity = activity.object as IncomingActivity;
|
||||||
|
|
||||||
if (!originalActivity || !originalActivity.type) {
|
if (!originalActivity || !originalActivity.type) {
|
||||||
return { success: false, error: 'Invalid undo target' };
|
return { success: false, error: 'Invalid undo target' };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Received undo for:', originalActivity.type, 'from:', activity.actor);
|
console.log('[Inbox] Received undo for:', originalActivity.type, 'from:', activity.actor);
|
||||||
|
|
||||||
// TODO: Handle undo based on original activity type
|
// Handle Undo Follow (unfollow)
|
||||||
|
if (originalActivity.type === 'Follow') {
|
||||||
|
// If we don't have the target user, try to find them
|
||||||
|
if (!targetUser) {
|
||||||
|
const targetActorUrl = typeof originalActivity.object === 'string'
|
||||||
|
? originalActivity.object
|
||||||
|
: (originalActivity.object as { id?: string })?.id;
|
||||||
|
|
||||||
|
if (targetActorUrl) {
|
||||||
|
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
|
||||||
|
if (handleMatch) {
|
||||||
|
targetUser = (await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handleMatch[1].toLowerCase()),
|
||||||
|
})) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser) {
|
||||||
|
// Remove the remote follower
|
||||||
|
const existingFollower = await db.query.remoteFollowers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(remoteFollowers.userId, targetUser.id),
|
||||||
|
eq(remoteFollowers.actorUrl, activity.actor)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingFollower) {
|
||||||
|
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollower.id));
|
||||||
|
|
||||||
|
// Update follower count
|
||||||
|
await db.update(users)
|
||||||
|
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||||
|
.where(eq(users.id, targetUser.id));
|
||||||
|
|
||||||
|
console.log(`[Inbox] Removed remote follower: ${activity.actor}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -169,7 +328,7 @@ async function handleUndo(activity: IncomingActivity): Promise<{ success: boolea
|
|||||||
* Handle Delete activities
|
* Handle Delete activities
|
||||||
*/
|
*/
|
||||||
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
console.log('Received delete from:', activity.actor);
|
console.log('[Inbox] Received delete from:', activity.actor);
|
||||||
|
|
||||||
// TODO: Remove cached remote content
|
// TODO: Remove cached remote content
|
||||||
|
|
||||||
@@ -180,9 +339,9 @@ async function handleDelete(activity: IncomingActivity): Promise<{ success: bool
|
|||||||
* Handle Accept activities (follow accepted)
|
* Handle Accept activities (follow accepted)
|
||||||
*/
|
*/
|
||||||
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
console.log('Follow accepted by:', activity.actor);
|
console.log('[Inbox] Follow accepted by:', activity.actor);
|
||||||
|
|
||||||
// TODO: Update follow status
|
// TODO: Update follow status in remoteFollows table
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -191,9 +350,9 @@ async function handleAccept(activity: IncomingActivity): Promise<{ success: bool
|
|||||||
* Handle Reject activities (follow rejected)
|
* Handle Reject activities (follow rejected)
|
||||||
*/
|
*/
|
||||||
async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
console.log('Follow rejected by:', activity.actor);
|
console.log('[Inbox] Follow rejected by:', activity.actor);
|
||||||
|
|
||||||
// TODO: Remove pending follow
|
// TODO: Remove pending follow from remoteFollows table
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -214,11 +373,11 @@ async function handleMove(activity: IncomingActivity): Promise<{ success: boolea
|
|||||||
return { success: false, error: 'Invalid move activity' };
|
return { success: false, error: 'Invalid move activity' };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Received Move activity: ${oldActorUrl} -> ${newActorUrl}`);
|
console.log(`[Inbox] Received Move activity: ${oldActorUrl} -> ${newActorUrl}`);
|
||||||
|
|
||||||
// Check if this is a Synapsis node with DID support
|
// Check if this is a Synapsis node with DID support
|
||||||
if (did) {
|
if (did) {
|
||||||
console.log(`Move includes DID: ${did} - attempting automatic migration`);
|
console.log(`[Inbox] Move includes DID: ${did} - attempting automatic migration`);
|
||||||
|
|
||||||
// Find any local follows that match this DID
|
// Find any local follows that match this DID
|
||||||
// This would require querying by the remote user's DID
|
// This would require querying by the remote user's DID
|
||||||
@@ -232,14 +391,14 @@ async function handleMove(activity: IncomingActivity): Promise<{ success: boolea
|
|||||||
// For Synapsis-to-Synapsis migrations, we can auto-follow
|
// For Synapsis-to-Synapsis migrations, we can auto-follow
|
||||||
// because we trust the DID verification
|
// because we trust the DID verification
|
||||||
|
|
||||||
console.log(`DID-based migration supported. Followers will be auto-migrated.`);
|
console.log(`[Inbox] DID-based migration supported. Followers will be auto-migrated.`);
|
||||||
|
|
||||||
// TODO: Implement automatic follow migration
|
// TODO: Implement automatic follow migration
|
||||||
// await migrateFollowersByDid(did, oldActorUrl, newActorUrl);
|
// await migrateFollowersByDid(did, oldActorUrl, newActorUrl);
|
||||||
} else {
|
} else {
|
||||||
// Standard Fediverse Move - just log it
|
// Standard Fediverse Move - just log it
|
||||||
// Users will need to manually re-follow
|
// Users will need to manually re-follow
|
||||||
console.log('Standard Move activity (no DID). Manual re-follow required.');
|
console.log('[Inbox] Standard Move activity (no DID). Manual re-follow required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
Reference in New Issue
Block a user