Account migration
This commit is contained in:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user