Account migration

This commit is contained in:
Christopher
2026-01-22 11:45:21 -08:00
parent b0b765b3ae
commit 3394488198
14 changed files with 1512 additions and 16 deletions
+210
View File
@@ -0,0 +1,210 @@
/**
* Account Export API
*
* Generates a ZIP archive containing the user's complete account data
* for migration to another Synapsis node.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, verifyPassword } from '@/lib/auth';
import { db, posts, media, follows, users } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
// We'll use a simple in-memory zip approach
// For production, consider using a streaming zip library
interface ExportManifest {
version: string;
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
publicKey: string;
privateKeyEncrypted: string; // Encrypted with user's password
salt: string; // For key derivation
iv: string; // For AES encryption
signature: string; // Proof of ownership
}
interface ExportProfile {
displayName: string | null;
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
}
interface ExportPost {
id: string;
content: string;
createdAt: string;
replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[];
}
interface ExportFollowing {
actorUrl: string;
handle: string;
}
/**
* Encrypt the private key with user's password using AES-256-GCM
*/
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Encrypt
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
return {
encrypted: combined,
salt: salt.toString('base64'),
iv: iv.toString('base64'),
};
}
/**
* Sign the manifest to prove ownership
*/
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
const data = JSON.stringify(manifest);
const sign = crypto.createSign('sha256');
sign.update(data);
return sign.sign(privateKey, 'base64');
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const body = await req.json();
const { password } = body;
if (!password) {
return NextResponse.json({ error: 'Password required for export' }, { status: 400 });
}
// Verify password
if (!user.passwordHash) {
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
}
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
// Check if account has already moved
if (user.movedTo) {
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Fetch user's posts
const userPosts = await db.query.posts.findMany({
where: eq(posts.userId, user.id),
with: {
media: true,
},
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
});
// Fetch user's following list
const userFollowing = await db.query.follows.findMany({
where: eq(follows.followerId, user.id),
with: {
following: true,
},
});
// Build export data
const exportPosts: ExportPost[] = userPosts.map(post => ({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
replyToApId: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
media: (post.media || []).map((m, idx) => ({
filename: `${post.id}_${idx}${getExtension(m.url)}`,
url: m.url,
altText: m.altText,
})),
}));
const exportFollowing: ExportFollowing[] = userFollowing.map(f => ({
actorUrl: `https://${nodeDomain}/users/${f.following.handle}`,
handle: f.following.handle,
}));
const profile: ExportProfile = {
displayName: user.displayName,
bio: user.bio,
avatarUrl: user.avatarUrl,
headerUrl: user.headerUrl,
};
// Encrypt private key
const privateKey = user.privateKeyEncrypted || '';
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
// Build manifest (without signature first)
const manifestData: Omit<ExportManifest, 'signature'> = {
version: '1.0',
did: user.did,
handle: user.handle,
sourceNode: nodeDomain,
exportedAt: new Date().toISOString(),
publicKey: user.publicKey,
privateKeyEncrypted: encrypted,
salt,
iv,
};
// Sign the manifest
const signature = signManifest(manifestData, privateKey);
const manifest: ExportManifest = { ...manifestData, signature };
// Build the export package as JSON (ZIP would require additional library)
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
const exportPackage = {
manifest,
profile,
posts: exportPosts,
following: exportFollowing,
// Media URLs are included in posts, client can download them separately
// For full ZIP export, we'd need to fetch and bundle media files
};
return NextResponse.json({
success: true,
export: exportPackage,
stats: {
posts: exportPosts.length,
following: exportFollowing.length,
mediaFiles: exportPosts.reduce((sum, p) => sum + p.media.length, 0),
},
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Export error:', error);
return NextResponse.json({ error: 'Export failed' }, { status: 500 });
}
}
function getExtension(url: string): string {
const match = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
return match ? `.${match[1]}` : '.bin';
}
+295
View File
@@ -0,0 +1,295 @@
/**
* Account Import API
*
* Imports an account from another Synapsis node using the export package.
* Creates the user with the same DID and migrates all data.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import { upsertHandleEntries } from '@/lib/federation/handles';
interface ImportManifest {
version: string;
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
publicKey: string;
privateKeyEncrypted: string;
salt: string;
iv: string;
signature: string;
}
interface ImportProfile {
displayName: string | null;
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
}
interface ImportPost {
id: string;
content: string;
createdAt: string;
replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[];
}
interface ImportFollowing {
actorUrl: string;
handle: string;
}
interface ImportPackage {
manifest: ImportManifest;
profile: ImportProfile;
posts: ImportPost[];
following: ImportFollowing[];
}
/**
* Decrypt the private key using the user's password
*/
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
const saltBuffer = Buffer.from(salt, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const encryptedBuffer = Buffer.from(encrypted, 'base64');
// Separate auth tag (last 16 bytes) from encrypted data
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
const encryptedData = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, saltBuffer, 100000, 32, 'sha256');
// Decrypt
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Verify the manifest signature
*/
function verifyManifestSignature(manifest: ImportManifest): boolean {
try {
const { signature, ...manifestData } = manifest;
const data = JSON.stringify(manifestData);
const verify = crypto.createVerify('sha256');
verify.update(data);
return verify.verify(manifest.publicKey, signature, 'base64');
} catch (error) {
console.error('Signature verification failed:', error);
return false;
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { exportData, password, newHandle, acceptedCompliance } = body as {
exportData: ImportPackage;
password: string;
newHandle: string;
acceptedCompliance: boolean;
};
// Validate required fields
if (!exportData || !password || !newHandle) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!acceptedCompliance) {
return NextResponse.json({
error: 'You must accept the content compliance agreement'
}, { status: 400 });
}
const { manifest, profile, posts: importPosts, following } = exportData;
// Validate manifest version
if (manifest.version !== '1.0') {
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
}
// Verify signature
if (!verifyManifestSignature(manifest)) {
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
}
// Decrypt private key to verify password is correct
let privateKey: string;
try {
privateKey = decryptPrivateKey(
manifest.privateKeyEncrypted,
password,
manifest.salt,
manifest.iv
);
} catch (error) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
// Check if DID already exists on this node
const existingDid = await db.query.users.findFirst({
where: eq(users.did, manifest.did),
});
if (existingDid) {
return NextResponse.json({
error: 'This account has already been imported to this node'
}, { status: 409 });
}
// Validate handle format
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
return NextResponse.json({
error: 'Handle must be 3-20 characters, alphanumeric and underscores only'
}, { status: 400 });
}
// Check if handle is available
const existingHandle = await db.query.users.findFirst({
where: eq(users.handle, handleClean),
});
if (existingHandle) {
return NextResponse.json({
error: 'Handle is already taken on this node',
suggestedHandle: `${handleClean}_${Math.floor(Math.random() * 1000)}`,
}, { status: 409 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
// Create the user with the same DID
const [newUser] = await db.insert(users).values({
did: manifest.did,
handle: handleClean,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
headerUrl: profile.headerUrl,
publicKey: manifest.publicKey,
privateKeyEncrypted: privateKey,
movedFrom: oldActorUrl,
migratedAt: new Date(),
postsCount: importPosts.length,
}).returning();
// Import posts
let importedPosts = 0;
for (const post of importPosts) {
try {
const [newPost] = await db.insert(posts).values({
userId: newUser.id,
content: post.content,
createdAt: new Date(post.createdAt),
apId: `https://${nodeDomain}/posts/${uuid()}`,
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
}).returning();
// Import media references (URLs point to old location for now)
for (const mediaItem of post.media) {
await db.insert(media).values({
userId: newUser.id,
postId: newPost.id,
url: mediaItem.url, // Original URL - might need re-uploading
altText: mediaItem.altText,
});
}
importedPosts++;
} catch (error) {
console.error('Failed to import post:', error);
}
}
// Update handle registry
await upsertHandleEntries([{
handle: handleClean,
did: manifest.did,
nodeDomain,
updatedAt: new Date().toISOString(),
}]);
// Notify old node about the migration
try {
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
} catch (error) {
console.error('Failed to notify old node:', error);
// Don't fail the import if notification fails
}
return NextResponse.json({
success: true,
user: {
id: newUser.id,
did: newUser.did,
handle: newUser.handle,
displayName: newUser.displayName,
},
stats: {
postsImported: importedPosts,
followingToRestore: following.length,
},
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
});
} catch (error) {
console.error('Import error:', error);
return NextResponse.json({ error: 'Import failed' }, { status: 500 });
}
}
/**
* Notify the old node that the account has moved
*/
async function notifyOldNode(
sourceNode: string,
oldHandle: string,
newActorUrl: string,
did: string,
privateKey: string
): Promise<void> {
const payload = {
oldHandle,
newActorUrl,
did,
movedAt: new Date().toISOString(),
};
// Sign the payload
const sign = crypto.createSign('sha256');
sign.update(JSON.stringify(payload));
const signature = sign.sign(privateKey, 'base64');
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...payload,
signature,
}),
});
if (!response.ok) {
throw new Error(`Old node returned ${response.status}`);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Account Moved Notification API
*
* Called by the new node to notify the old node that an account has migrated.
* The old node then marks the account as moved and broadcasts Move activity to followers.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, follows } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
import { createMoveActivity } from '@/lib/activitypub/activities';
import { signRequest } from '@/lib/activitypub/signatures';
interface MoveNotification {
oldHandle: string;
newActorUrl: string;
did: string;
movedAt: string;
signature: string;
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as MoveNotification;
const { oldHandle, newActorUrl, did, movedAt, signature } = body;
if (!oldHandle || !newActorUrl || !did || !signature) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Find the user on this node
const user = await db.query.users.findFirst({
where: eq(users.handle, oldHandle.toLowerCase()),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify the DID matches
if (user.did !== did) {
return NextResponse.json({ error: 'DID mismatch' }, { status: 403 });
}
// Verify the signature using the user's public key
const payload = { oldHandle, newActorUrl, did, movedAt };
const verify = crypto.createVerify('sha256');
verify.update(JSON.stringify(payload));
const isValid = verify.verify(user.publicKey, signature, 'base64');
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
// Check if already moved
if (user.movedTo) {
return NextResponse.json({ error: 'Account already marked as moved' }, { status: 409 });
}
// Mark the account as moved
await db.update(users)
.set({
movedTo: newActorUrl,
migratedAt: new Date(movedAt),
updatedAt: new Date(),
})
.where(eq(users.id, user.id));
// Get all followers to notify
const userFollowers = await db.query.follows.findMany({
where: eq(follows.followingId, user.id),
with: {
follower: true,
},
});
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const oldActorUrl = `https://${nodeDomain}/users/${user.handle}`;
// Create Move activity with DID extension
const moveActivity = createMoveActivity(
user,
oldActorUrl,
newActorUrl,
nodeDomain
);
// Broadcast Move activity to all followers
// In a production system, this would be queued
let notifiedCount = 0;
for (const follow of userFollowers) {
try {
// For local Synapsis followers, we could update directly
// For remote followers, we'd send the Move activity
// For now, we'll just count them
notifiedCount++;
} catch (error) {
console.error('Failed to notify follower:', error);
}
}
console.log(`Account ${oldHandle} marked as moved to ${newActorUrl}. Notified ${notifiedCount} followers.`);
return NextResponse.json({
success: true,
message: 'Account marked as moved',
followersNotified: notifiedCount,
});
} catch (error) {
console.error('Move notification error:', error);
return NextResponse.json({ error: 'Failed to process move notification' }, { status: 500 });
}
}