Many improvments and bug fixes
This commit is contained in:
@@ -18,6 +18,12 @@ interface UserSummary {
|
|||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip HTML tags from a string
|
||||||
|
const stripHtml = (html: string | null | undefined): string | null => {
|
||||||
|
if (!html) return null;
|
||||||
|
return html.replace(/<[^>]*>/g, '').trim() || null;
|
||||||
|
};
|
||||||
|
|
||||||
function UserRow({ user }: { user: UserSummary }) {
|
function UserRow({ user }: { user: UserSummary }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.handle}`} className="user-row">
|
<Link href={`/${user.handle}`} className="user-row">
|
||||||
@@ -31,8 +37,8 @@ function UserRow({ user }: { user: UserSummary }) {
|
|||||||
<div className="user-row-content">
|
<div className="user-row-content">
|
||||||
<div style={{ fontWeight: 600 }}>{user.displayName || user.handle}</div>
|
<div style={{ fontWeight: 600 }}>{user.displayName || user.handle}</div>
|
||||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div>
|
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div>
|
||||||
{user.bio && (
|
{user.bio && stripHtml(user.bio) && (
|
||||||
<div className="user-row-bio">{user.bio}</div>
|
<div className="user-row-bio">{stripHtml(user.bio)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, verifyPassword } from '@/lib/auth';
|
import { requireAuth, verifyPassword } from '@/lib/auth';
|
||||||
import { db, posts, media, follows, users } from '@/db';
|
import { db, posts, media, follows, users, remoteFollows } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@@ -45,6 +45,10 @@ interface ExportPost {
|
|||||||
interface ExportFollowing {
|
interface ExportFollowing {
|
||||||
actorUrl: string;
|
actorUrl: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
|
isRemote?: boolean;
|
||||||
|
displayName?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +124,7 @@ export async function POST(req: NextRequest) {
|
|||||||
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch user's following list
|
// Fetch user's following list (local and remote)
|
||||||
const userFollowing = await db.query.follows.findMany({
|
const userFollowing = await db.query.follows.findMany({
|
||||||
where: eq(follows.followerId, user.id),
|
where: eq(follows.followerId, user.id),
|
||||||
with: {
|
with: {
|
||||||
@@ -128,6 +132,10 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userRemoteFollowing = await db.query.remoteFollows.findMany({
|
||||||
|
where: eq(remoteFollows.followerId, user.id),
|
||||||
|
});
|
||||||
|
|
||||||
// Build export data
|
// Build export data
|
||||||
const exportPosts: ExportPost[] = userPosts.map(post => ({
|
const exportPosts: ExportPost[] = userPosts.map(post => ({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@@ -141,10 +149,23 @@ export async function POST(req: NextRequest) {
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const exportFollowing: ExportFollowing[] = userFollowing.map(f => ({
|
const exportFollowing: ExportFollowing[] = [
|
||||||
|
// Local follows
|
||||||
|
...userFollowing.map(f => ({
|
||||||
actorUrl: `https://${nodeDomain}/users/${f.following.handle}`,
|
actorUrl: `https://${nodeDomain}/users/${f.following.handle}`,
|
||||||
handle: f.following.handle,
|
handle: f.following.handle,
|
||||||
}));
|
isRemote: false,
|
||||||
|
})),
|
||||||
|
// Remote follows
|
||||||
|
...userRemoteFollowing.map(f => ({
|
||||||
|
actorUrl: f.targetActorUrl,
|
||||||
|
handle: f.targetHandle,
|
||||||
|
isRemote: true,
|
||||||
|
displayName: f.displayName,
|
||||||
|
bio: f.bio,
|
||||||
|
avatarUrl: f.avatarUrl,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
const profile: ExportProfile = {
|
const profile: ExportProfile = {
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
|||||||
@@ -59,7 +59,35 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Federate the like
|
// Federate the like if the post has an ActivityPub ID
|
||||||
|
if (post.apId) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { createLikeActivity } = await import('@/lib/activitypub/activities');
|
||||||
|
const { deliverActivity } = await import('@/lib/activitypub/outbox');
|
||||||
|
const { fetchRemoteActor } = await import('@/lib/activitypub/fetch');
|
||||||
|
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
|
// Get the post author's actor URL
|
||||||
|
const postWithAuthor = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.id, postId),
|
||||||
|
with: { author: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!postWithAuthor?.author) return;
|
||||||
|
|
||||||
|
// Construct the author's actor URL
|
||||||
|
const authorActorUrl = `https://${nodeDomain}/users/${postWithAuthor.author.handle}`;
|
||||||
|
|
||||||
|
// For remote posts, we'd need to fetch the remote actor's inbox
|
||||||
|
// For local posts, just log it since local delivery doesn't need federation
|
||||||
|
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Federation] Error federating like:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, liked: true });
|
return NextResponse.json({ success: true, liked: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -69,7 +69,36 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Federate the repost (Announce activity)
|
// Federate the repost (Announce activity) if the original post has an ActivityPub ID
|
||||||
|
const postApId = originalPost.apId;
|
||||||
|
if (postApId) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { createAnnounceActivity } = await import('@/lib/activitypub/activities');
|
||||||
|
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||||
|
|
||||||
|
// Send Announce to our followers
|
||||||
|
const followerInboxes = await getFollowerInboxes(user.id);
|
||||||
|
if (followerInboxes.length > 0) {
|
||||||
|
const announceActivity = createAnnounceActivity(
|
||||||
|
user,
|
||||||
|
postApId,
|
||||||
|
nodeDomain,
|
||||||
|
repost.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateKey = user.privateKeyEncrypted;
|
||||||
|
if (privateKey) {
|
||||||
|
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
|
||||||
|
const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId);
|
||||||
|
console.log(`[Federation] Announce for ${postApId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Federation] Error federating repost:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, repost, reposted: true });
|
return NextResponse.json({ success: true, repost, reposted: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -89,7 +89,33 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Federate the post to followers
|
// Federate the post to remote followers (non-blocking)
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { createCreateActivity } = await import('@/lib/activitypub/activities');
|
||||||
|
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||||
|
|
||||||
|
const followerInboxes = await getFollowerInboxes(user.id);
|
||||||
|
if (followerInboxes.length === 0) {
|
||||||
|
return; // No remote followers to notify
|
||||||
|
}
|
||||||
|
|
||||||
|
const createActivity = createCreateActivity(post, user, nodeDomain);
|
||||||
|
|
||||||
|
const privateKey = user.privateKeyEncrypted;
|
||||||
|
if (!privateKey) {
|
||||||
|
console.error('[Federation] User has no private key for signing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
|
||||||
|
const result = await deliverToFollowers(createActivity, followerInboxes, privateKey, keyId);
|
||||||
|
console.log(`[Federation] Post ${post.id} delivered to ${result.delivered}/${followerInboxes.length} inboxes (${result.failed} failed)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Federation] Error federating post:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
|
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ const parseRemoteHandle = (handle: string) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
|
||||||
|
const stripHtml = (html: string | null | undefined): string | null => {
|
||||||
|
if (!html) return null;
|
||||||
|
return html.replace(/<[^>]*>/g, '').trim() || null;
|
||||||
|
};
|
||||||
|
|
||||||
// Check follow status
|
// Check follow status
|
||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
@@ -126,12 +132,26 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract avatar URL from remote profile
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (remoteProfile.icon) {
|
||||||
|
if (typeof remoteProfile.icon === 'string') {
|
||||||
|
avatarUrl = remoteProfile.icon;
|
||||||
|
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
|
||||||
|
avatarUrl = remoteProfile.icon.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(remoteFollows).values({
|
await db.insert(remoteFollows).values({
|
||||||
followerId: currentUser.id,
|
followerId: currentUser.id,
|
||||||
targetHandle,
|
targetHandle,
|
||||||
targetActorUrl: remoteProfile.id,
|
targetActorUrl: remoteProfile.id,
|
||||||
inboxUrl: targetInbox,
|
inboxUrl: targetInbox,
|
||||||
activityId,
|
activityId,
|
||||||
|
displayName: remoteProfile.name || null,
|
||||||
|
bio: stripHtml(remoteProfile.summary),
|
||||||
|
avatarUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the user's following count
|
// Update the user's following count
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const remoteFollowing = userRemoteFollowing.map(f => ({
|
const remoteFollowing = userRemoteFollowing.map(f => ({
|
||||||
id: f.targetActorUrl,
|
id: f.targetActorUrl,
|
||||||
handle: f.targetHandle,
|
handle: f.targetHandle,
|
||||||
displayName: f.targetHandle.split('@')[0], // Use username part as display name
|
displayName: f.displayName || f.targetHandle.split('@')[0], // Use stored display name or username part
|
||||||
avatarUrl: null,
|
avatarUrl: f.avatarUrl,
|
||||||
bio: null,
|
bio: f.bio,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,15 @@ a.btn-primary:visited {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-row .avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row .avatar img {
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--background-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-sm {
|
.avatar-sm {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
+232
-4
@@ -4,10 +4,11 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { SynapsisLogo } from '@/components/Icons';
|
import { SynapsisLogo } from '@/components/Icons';
|
||||||
|
import { TriangleAlert, ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
@@ -18,6 +19,13 @@ export default function LoginPage() {
|
|||||||
const [nodeInfo, setNodeInfo] = useState({ name: '', description: '' });
|
const [nodeInfo, setNodeInfo] = useState({ name: '', description: '' });
|
||||||
const [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
|
const [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
|
||||||
|
|
||||||
|
// Import specific state
|
||||||
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
|
const [importPassword, setImportPassword] = useState('');
|
||||||
|
const [importHandle, setImportHandle] = useState('');
|
||||||
|
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
|
||||||
|
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch node info
|
// Fetch node info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node')
|
fetch('/api/node')
|
||||||
@@ -33,7 +41,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
// Handle availability check
|
// Handle availability check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'register' || !handle || handle.length < 3) {
|
const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : '');
|
||||||
|
if (!checkHandle || checkHandle.length < 3) {
|
||||||
setHandleStatus('idle');
|
setHandleStatus('idle');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -41,7 +50,7 @@ export default function LoginPage() {
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setHandleStatus('checking');
|
setHandleStatus('checking');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/auth/check-handle?handle=${handle}`);
|
const res = await fetch(`/api/auth/check-handle?handle=${checkHandle}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.available) {
|
if (data.available) {
|
||||||
setHandleStatus('available');
|
setHandleStatus('available');
|
||||||
@@ -54,7 +63,54 @@ export default function LoginPage() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [handle, mode]);
|
}, [handle, importHandle, mode]);
|
||||||
|
|
||||||
|
const handleImport = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!importFile || !importPassword || !importHandle || !acceptedCompliance) {
|
||||||
|
setError('Please fill in all fields and accept the compliance agreement');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setImportSuccess(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileContent = await importFile.text();
|
||||||
|
const exportData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
const res = await fetch('/api/account/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
exportData,
|
||||||
|
password: importPassword,
|
||||||
|
newHandle: importHandle,
|
||||||
|
acceptedCompliance,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Import failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportSuccess(data.message);
|
||||||
|
// After successful import, the user is typically logged in (depends on API implementation)
|
||||||
|
// But let's redirect to login or home if the API returns success
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Import failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -126,6 +182,7 @@ export default function LoginPage() {
|
|||||||
background: 'var(--background-secondary)',
|
background: 'var(--background-secondary)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
padding: '4px',
|
padding: '4px',
|
||||||
|
gap: '4px'
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('login')}
|
onClick={() => setMode('login')}
|
||||||
@@ -159,9 +216,26 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('import')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: mode === 'import' ? 'var(--background-tertiary)' : 'transparent',
|
||||||
|
color: mode === 'import' ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
|
{mode !== 'import' ? (
|
||||||
<form onSubmit={handleSubmit} className="card" style={{ padding: '24px' }}>
|
<form onSubmit={handleSubmit} className="card" style={{ padding: '24px' }}>
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -295,6 +369,160 @@ export default function LoginPage() {
|
|||||||
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleImport} className="card" style={{ padding: '24px' }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid var(--error)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--error)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importSuccess && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
background: 'var(--success)',
|
||||||
|
border: '1px solid var(--success)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
{importSuccess} Redirecting...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Export file
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="import-file-input"
|
||||||
|
accept=".json"
|
||||||
|
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="import-file-input"
|
||||||
|
className="input"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: importFile ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{importFile ? importFile.name : 'Select export file...'}</span>
|
||||||
|
<span className="btn btn-ghost btn-sm" style={{ pointerEvents: 'none', padding: '4px 8px', height: 'auto', minHeight: 'unset' }}>
|
||||||
|
Browse
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Password (from your old account)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={importPassword}
|
||||||
|
onChange={(e) => setImportPassword(e.target.value)}
|
||||||
|
placeholder="Enter the password for this account"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Handle on this node
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '12px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
}}>@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={importHandle}
|
||||||
|
onChange={(e) => setImportHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||||
|
style={{ paddingLeft: '28px' }}
|
||||||
|
placeholder="yourhandle"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
marginTop: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--foreground-tertiary)' }}>
|
||||||
|
3-20 chars
|
||||||
|
</span>
|
||||||
|
{handleStatus === 'checking' && (
|
||||||
|
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span>
|
||||||
|
)}
|
||||||
|
{handleStatus === 'available' && (
|
||||||
|
<span style={{ color: 'var(--success)', fontWeight: 600 }}>Available</span>
|
||||||
|
)}
|
||||||
|
{handleStatus === 'taken' && (
|
||||||
|
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(245, 158, 11, 0.05)',
|
||||||
|
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acceptedCompliance}
|
||||||
|
onChange={(e) => setAcceptedCompliance(e.target.checked)}
|
||||||
|
style={{ marginTop: '3px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||||
|
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<TriangleAlert size={12} /> Compliance:
|
||||||
|
</strong> I agree to comply with this node's rules and take responsibility for my migrated content.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-lg"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance}
|
||||||
|
>
|
||||||
|
{loading ? 'Importing...' : 'Import Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||||
<Link href="/">← Back to home</Link>
|
<Link href="/">← Back to home</Link>
|
||||||
|
|||||||
@@ -14,50 +14,13 @@ interface ExportStats {
|
|||||||
|
|
||||||
export default function MigrationPage() {
|
export default function MigrationPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState<'export' | 'import'>('export');
|
|
||||||
|
|
||||||
// Export state
|
// Export state
|
||||||
const [exportPassword, setExportPassword] = useState('');
|
const [exportPassword, setExportPassword] = useState('');
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
const [exportData, setExportData] = useState<object | null>(null);
|
|
||||||
const [exportStats, setExportStats] = useState<ExportStats | null>(null);
|
const [exportStats, setExportStats] = useState<ExportStats | null>(null);
|
||||||
|
|
||||||
// Import state
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
|
||||||
const [importPassword, setImportPassword] = useState('');
|
|
||||||
const [importHandle, setImportHandle] = useState('');
|
|
||||||
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
|
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
|
||||||
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
|
||||||
const [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
|
|
||||||
|
|
||||||
// Handle availability check
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== 'import' || !importHandle || importHandle.length < 3) {
|
|
||||||
setHandleStatus('idle');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
setHandleStatus('checking');
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/auth/check-handle?handle=${importHandle}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.available) {
|
|
||||||
setHandleStatus('available');
|
|
||||||
} else {
|
|
||||||
setHandleStatus('taken');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setHandleStatus('idle');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [importHandle, activeTab]);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!exportPassword) {
|
if (!exportPassword) {
|
||||||
setExportError('Please enter your password');
|
setExportError('Please enter your password');
|
||||||
@@ -80,7 +43,6 @@ export default function MigrationPage() {
|
|||||||
throw new Error(data.error || 'Export failed');
|
throw new Error(data.error || 'Export failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
setExportData(data.export);
|
|
||||||
setExportStats(data.stats);
|
setExportStats(data.stats);
|
||||||
|
|
||||||
// Trigger download
|
// Trigger download
|
||||||
@@ -101,59 +63,7 @@ export default function MigrationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
if (!user) {
|
||||||
if (!importFile) {
|
|
||||||
setImportError('Please select an export file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!importPassword) {
|
|
||||||
setImportError('Please enter your password');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!importHandle) {
|
|
||||||
setImportError('Please enter a handle for this node');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!acceptedCompliance) {
|
|
||||||
setImportError('Please accept the content compliance agreement');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsImporting(true);
|
|
||||||
setImportError(null);
|
|
||||||
setImportSuccess(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileContent = await importFile.text();
|
|
||||||
const exportData = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
const res = await fetch('/api/account/import', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
exportData,
|
|
||||||
password: importPassword,
|
|
||||||
newHandle: importHandle,
|
|
||||||
acceptedCompliance,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || 'Import failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setImportSuccess(data.message);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
setImportError(error instanceof Error ? error.message : 'Import failed');
|
|
||||||
} finally {
|
|
||||||
setIsImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user && activeTab === 'export') {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||||
<header style={{
|
<header style={{
|
||||||
@@ -166,12 +76,12 @@ export default function MigrationPage() {
|
|||||||
<ArrowLeftIcon />
|
<ArrowLeftIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Account Migration</h1>
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Export Account</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="card" style={{ padding: '24px', textAlign: 'center' }}>
|
<div className="card" style={{ padding: '24px', textAlign: 'center' }}>
|
||||||
<p style={{ marginBottom: '16px' }}>Please log in to export your account, or switch to Import to migrate an account here.</p>
|
<p style={{ marginBottom: '16px' }}>Please log in to export your account.</p>
|
||||||
<button className="btn" onClick={() => setActiveTab('import')}>Switch to Import</button>
|
<Link href="/login" className="btn btn-primary">Log In</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -189,57 +99,21 @@ export default function MigrationPage() {
|
|||||||
<ArrowLeftIcon />
|
<ArrowLeftIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Account Migration</h1>
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Export Account</h1>
|
||||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||||
Move your identity between Synapsis nodes
|
Download a backup of your identity and content
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div style={{ display: 'flex', marginBottom: '24px', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('export')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: activeTab === 'export' ? '2px solid var(--accent)' : '2px solid transparent',
|
|
||||||
color: activeTab === 'export' ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
|
||||||
fontWeight: activeTab === 'export' ? 600 : 400,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export Account
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('import')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: activeTab === 'import' ? '2px solid var(--accent)' : '2px solid transparent',
|
|
||||||
color: activeTab === 'import' ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
|
||||||
fontWeight: activeTab === 'import' ? 600 : 400,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Import Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export Tab */}
|
|
||||||
{activeTab === 'export' && user && (
|
|
||||||
<div className="card" style={{ padding: '24px' }}>
|
<div className="card" style={{ padding: '24px' }}>
|
||||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
|
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
|
||||||
Export Your Account
|
Download Your Data
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
||||||
Download a complete backup of your account including your identity, posts, and media.
|
Download a complete backup of your account including your identity, posts, and media.
|
||||||
You can use this file to migrate to another Synapsis node.
|
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -323,164 +197,6 @@ export default function MigrationPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Import Tab */}
|
|
||||||
{activeTab === 'import' && (
|
|
||||||
<div className="card" style={{ padding: '24px' }}>
|
|
||||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
|
|
||||||
Import an Account
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
|
||||||
Migrate an account from another Synapsis node. Your DID will be preserved,
|
|
||||||
and followers on other Synapsis nodes will be automatically migrated.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
|
||||||
Export file
|
|
||||||
</label>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="import-file-input"
|
|
||||||
accept=".json"
|
|
||||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="import-file-input"
|
|
||||||
className="input"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: importFile ? 'var(--foreground)' : 'var(--foreground-tertiary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{importFile ? importFile.name : 'Select export file...'}</span>
|
|
||||||
<span className="btn btn-ghost btn-sm" style={{ pointerEvents: 'none' }}>
|
|
||||||
Browse
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
|
||||||
Password (from your old account)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="input"
|
|
||||||
value={importPassword}
|
|
||||||
onChange={(e) => setImportPassword(e.target.value)}
|
|
||||||
placeholder="Enter the password for this account"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
|
||||||
Handle on this node
|
|
||||||
</label>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '12px',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
color: 'var(--foreground-tertiary)',
|
|
||||||
}}>@</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="input"
|
|
||||||
value={importHandle}
|
|
||||||
onChange={(e) => setImportHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
|
||||||
style={{ paddingLeft: '28px' }}
|
|
||||||
placeholder="yourhandle"
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
marginTop: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'var(--foreground-tertiary)' }}>
|
|
||||||
3-20 characters, alphanumeric and underscores
|
|
||||||
</span>
|
|
||||||
{handleStatus === 'checking' && (
|
|
||||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span>
|
|
||||||
)}
|
|
||||||
{handleStatus === 'available' && (
|
|
||||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>Available</span>
|
|
||||||
)}
|
|
||||||
{handleStatus === 'taken' && (
|
|
||||||
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compliance Agreement */}
|
|
||||||
<div style={{
|
|
||||||
marginBottom: '20px',
|
|
||||||
padding: '16px',
|
|
||||||
background: 'rgba(245, 158, 11, 0.1)',
|
|
||||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}>
|
|
||||||
<label style={{ display: 'flex', gap: '12px', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={acceptedCompliance}
|
|
||||||
onChange={(e) => setAcceptedCompliance(e.target.checked)}
|
|
||||||
style={{ marginTop: '4px' }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: '14px', color: 'var(--foreground-secondary)', lineHeight: 1.6 }}>
|
|
||||||
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<TriangleAlert size={14} /> Content Compliance:
|
|
||||||
</strong> All of your post history
|
|
||||||
and content will be migrated to this node. It is your responsibility to ensure your content
|
|
||||||
complies with this node's rules. If you migrate content that violates this node's rules,
|
|
||||||
you may be subject to any moderation action the node operator sees fit.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importError && (
|
|
||||||
<div style={{ color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}>
|
|
||||||
{importError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importSuccess && (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--success)',
|
|
||||||
color: '#000',
|
|
||||||
padding: '16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
}}>
|
|
||||||
{importSuccess}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={isImporting || !importFile || !importPassword || !importHandle || !acceptedCompliance}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{isImporting ? 'Importing...' : 'Import Account'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export default function SettingsPage() {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<Rocket size={18} />
|
<Rocket size={18} />
|
||||||
Account Migration
|
Export Account
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||||
Export your account or import from another Synapsis node
|
Download a backup of your account and content
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ export const remoteFollows = pgTable('remote_follows', {
|
|||||||
targetActorUrl: text('target_actor_url').notNull(),
|
targetActorUrl: text('target_actor_url').notNull(),
|
||||||
inboxUrl: text('inbox_url').notNull(),
|
inboxUrl: text('inbox_url').notNull(),
|
||||||
activityId: text('activity_id').notNull(), // UUID token for activity URL
|
activityId: text('activity_id').notNull(), // UUID token for activity URL
|
||||||
|
// Cached profile data for display
|
||||||
|
displayName: text('display_name'),
|
||||||
|
bio: text('bio'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('remote_follows_follower_idx').on(table.followerId),
|
index('remote_follows_follower_idx').on(table.followerId),
|
||||||
|
|||||||
+283
-20
@@ -4,7 +4,7 @@
|
|||||||
* Processes incoming activities from remote servers.
|
* Processes incoming activities from remote servers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, users, remoteFollowers } from '@/db';
|
import { db, users, remoteFollowers, remotePosts, posts, remoteFollows } 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 { createAcceptActivity } from './activities';
|
import { createAcceptActivity } from './activities';
|
||||||
@@ -107,18 +107,102 @@ export async function processIncomingActivity(
|
|||||||
* Handle Create activities (new posts)
|
* Handle Create activities (new posts)
|
||||||
*/
|
*/
|
||||||
async function handleCreate(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleCreate(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
const object = activity.object as { type: string; content?: string; id?: string; attributedTo?: string };
|
const object = activity.object as {
|
||||||
|
type: string;
|
||||||
|
content?: string;
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
attributedTo?: string;
|
||||||
|
published?: string;
|
||||||
|
attachment?: Array<{
|
||||||
|
type: string;
|
||||||
|
mediaType?: string;
|
||||||
|
url?: string;
|
||||||
|
name?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
if (object.type !== 'Note') {
|
if (object.type !== 'Note') {
|
||||||
return { success: true }; // We only handle Notes for now
|
return { success: true }; // We only handle Notes for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Store remote posts in database for caching/display
|
if (!object.id || !object.attributedTo) {
|
||||||
console.log('[Inbox] Received remote post:', object.id);
|
console.warn('[Inbox] Create activity missing id or attributedTo');
|
||||||
|
return { success: false, error: 'Missing required fields' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if we already have this post cached
|
||||||
|
const existingPost = await db.query.remotePosts.findFirst({
|
||||||
|
where: eq(remotePosts.apId, object.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPost) {
|
||||||
|
console.log('[Inbox] Post already cached:', object.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse author info from attributedTo URL
|
||||||
|
const authorUrl = new URL(object.attributedTo);
|
||||||
|
const authorPathParts = authorUrl.pathname.split('/').filter(Boolean);
|
||||||
|
const authorHandle = authorPathParts[authorPathParts.length - 1] || 'unknown';
|
||||||
|
const authorDomain = authorUrl.hostname;
|
||||||
|
const fullHandle = `${authorHandle}@${authorDomain}`;
|
||||||
|
|
||||||
|
// Fetch author profile for display name and avatar
|
||||||
|
let displayName: string | null = null;
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
try {
|
||||||
|
const actorResponse = await fetch(object.attributedTo, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/activity+json, application/ld+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (actorResponse.ok) {
|
||||||
|
const actorData = await actorResponse.json();
|
||||||
|
displayName = actorData.name || actorData.preferredUsername || null;
|
||||||
|
avatarUrl = actorData.icon?.url || actorData.icon || null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Inbox] Could not fetch actor profile:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse media attachments
|
||||||
|
let mediaJson: string | null = null;
|
||||||
|
if (object.attachment && object.attachment.length > 0) {
|
||||||
|
const mediaItems = object.attachment
|
||||||
|
.filter(att => att.type === 'Document' || att.type === 'Image' || att.type === 'Video')
|
||||||
|
.map(att => ({
|
||||||
|
url: att.url,
|
||||||
|
altText: att.name || null,
|
||||||
|
mediaType: att.mediaType,
|
||||||
|
}));
|
||||||
|
if (mediaItems.length > 0) {
|
||||||
|
mediaJson = JSON.stringify(mediaItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the remote post
|
||||||
|
await db.insert(remotePosts).values({
|
||||||
|
apId: object.id,
|
||||||
|
authorHandle: fullHandle,
|
||||||
|
authorActorUrl: object.attributedTo,
|
||||||
|
authorDisplayName: displayName,
|
||||||
|
authorAvatarUrl: avatarUrl,
|
||||||
|
content: object.content || '',
|
||||||
|
publishedAt: object.published ? new Date(object.published) : new Date(),
|
||||||
|
mediaJson: mediaJson,
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Inbox] Cached remote post from ${fullHandle}:`, object.id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error caching remote post:', error);
|
||||||
|
return { success: false, error: 'Failed to cache post' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle Follow activities
|
* Handle Follow activities
|
||||||
*/
|
*/
|
||||||
@@ -244,9 +328,27 @@ async function handleLike(activity: IncomingActivity): Promise<{ success: boolea
|
|||||||
return { success: false, error: 'Invalid like target' };
|
return { success: false, error: 'Invalid like target' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update like count on local post
|
|
||||||
console.log('[Inbox] Received like for:', targetUrl, 'from:', activity.actor);
|
console.log('[Inbox] Received like for:', targetUrl, 'from:', activity.actor);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the local post by its apId or apUrl
|
||||||
|
const post = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.apId, targetUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (post) {
|
||||||
|
// Increment like count
|
||||||
|
await db.update(posts)
|
||||||
|
.set({ likesCount: post.likesCount + 1 })
|
||||||
|
.where(eq(posts.id, post.id));
|
||||||
|
console.log(`[Inbox] Updated like count for post ${post.id}: ${post.likesCount + 1}`);
|
||||||
|
} else {
|
||||||
|
console.log('[Inbox] Like target not found locally:', targetUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error updating like count:', error);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +362,27 @@ async function handleAnnounce(activity: IncomingActivity): Promise<{ success: bo
|
|||||||
return { success: false, error: 'Invalid announce target' };
|
return { success: false, error: 'Invalid announce target' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update repost count on local post
|
|
||||||
console.log('[Inbox] Received announce for:', targetUrl, 'from:', activity.actor);
|
console.log('[Inbox] Received announce for:', targetUrl, 'from:', activity.actor);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the local post by its apId or apUrl
|
||||||
|
const post = await db.query.posts.findFirst({
|
||||||
|
where: eq(posts.apId, targetUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (post) {
|
||||||
|
// Increment repost count
|
||||||
|
await db.update(posts)
|
||||||
|
.set({ repostsCount: post.repostsCount + 1 })
|
||||||
|
.where(eq(posts.id, post.id));
|
||||||
|
console.log(`[Inbox] Updated repost count for post ${post.id}: ${post.repostsCount + 1}`);
|
||||||
|
} else {
|
||||||
|
console.log('[Inbox] Announce target not found locally:', targetUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error updating repost count:', error);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +450,36 @@ async function handleUndo(
|
|||||||
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
console.log('[Inbox] Received delete from:', activity.actor);
|
console.log('[Inbox] Received delete from:', activity.actor);
|
||||||
|
|
||||||
// TODO: Remove cached remote content
|
try {
|
||||||
|
// The object can be the deleted item's URL or an object with an id
|
||||||
|
const deletedId = typeof activity.object === 'string'
|
||||||
|
? activity.object
|
||||||
|
: (activity.object as { id?: string })?.id;
|
||||||
|
|
||||||
|
if (!deletedId) {
|
||||||
|
console.log('[Inbox] Delete activity missing object id');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find and remove cached remote post
|
||||||
|
const cachedPost = await db.query.remotePosts.findFirst({
|
||||||
|
where: eq(remotePosts.apId, deletedId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedPost) {
|
||||||
|
// Verify the delete is from the original author
|
||||||
|
if (cachedPost.authorActorUrl === activity.actor) {
|
||||||
|
await db.delete(remotePosts).where(eq(remotePosts.id, cachedPost.id));
|
||||||
|
console.log(`[Inbox] Deleted cached remote post: ${deletedId}`);
|
||||||
|
} else {
|
||||||
|
console.warn('[Inbox] Delete actor mismatch - ignoring');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Inbox] Deleted content not found in cache:', deletedId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error handling delete:', error);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -341,7 +490,51 @@ async function handleDelete(activity: IncomingActivity): Promise<{ success: bool
|
|||||||
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
|
||||||
console.log('[Inbox] Follow accepted by:', activity.actor);
|
console.log('[Inbox] Follow accepted by:', activity.actor);
|
||||||
|
|
||||||
// TODO: Update follow status in remoteFollows table
|
try {
|
||||||
|
// The object should be the original Follow activity
|
||||||
|
const followActivity = activity.object as { type?: string; actor?: string; object?: string };
|
||||||
|
|
||||||
|
if (followActivity?.type !== 'Follow') {
|
||||||
|
console.log('[Inbox] Accept is not for a Follow activity');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the local user who sent the follow
|
||||||
|
const localActorUrl = followActivity.actor;
|
||||||
|
if (!localActorUrl) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract handle from our actor URL
|
||||||
|
const handleMatch = localActorUrl.match(/\/users\/([^\/]+)$/);
|
||||||
|
if (!handleMatch) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.handle, handleMatch[1].toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!localUser) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and update the remote follow record
|
||||||
|
const remoteFollow = await db.query.remoteFollows.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(remoteFollows.followerId, localUser.id),
|
||||||
|
eq(remoteFollows.targetActorUrl, activity.actor)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remoteFollow) {
|
||||||
|
// The follow is now confirmed - we could add an 'accepted' flag if needed
|
||||||
|
// For now, just log it since the follow is already stored
|
||||||
|
console.log(`[Inbox] Follow to ${activity.actor} confirmed for @${localUser.handle}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error handling accept:', error);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -379,22 +572,92 @@ async function handleMove(activity: IncomingActivity): Promise<{ success: boolea
|
|||||||
if (did) {
|
if (did) {
|
||||||
console.log(`[Inbox] 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
|
try {
|
||||||
// This would require querying by the remote user's DID
|
// Find all local users following the old actor URL
|
||||||
// For now, we'll log the DID and handle it
|
const affectedFollows = await db.query.remoteFollows.findMany({
|
||||||
|
where: eq(remoteFollows.targetActorUrl, oldActorUrl),
|
||||||
|
});
|
||||||
|
|
||||||
// In a full implementation, we would:
|
if (affectedFollows.length === 0) {
|
||||||
// 1. Find all local users following the old actor URL
|
console.log('[Inbox] No local users following the migrating account');
|
||||||
// 2. Update their follow relationship to point to the new actor URL
|
return { success: true };
|
||||||
// 3. Automatically send a Follow to the new actor
|
}
|
||||||
|
|
||||||
// For Synapsis-to-Synapsis migrations, we can auto-follow
|
console.log(`[Inbox] Found ${affectedFollows.length} local users to migrate`);
|
||||||
// because we trust the DID verification
|
|
||||||
|
|
||||||
console.log(`[Inbox] DID-based migration supported. Followers will be auto-migrated.`);
|
// Fetch the new actor's info to get their inbox
|
||||||
|
const newActorResponse = await fetch(newActorUrl, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/activity+json, application/ld+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Implement automatic follow migration
|
if (!newActorResponse.ok) {
|
||||||
// await migrateFollowersByDid(did, oldActorUrl, newActorUrl);
|
console.error('[Inbox] Failed to fetch new actor profile');
|
||||||
|
return { success: true }; // Don't fail, just log
|
||||||
|
}
|
||||||
|
|
||||||
|
const newActor = await newActorResponse.json();
|
||||||
|
const newInbox = newActor.endpoints?.sharedInbox || newActor.inbox;
|
||||||
|
const newHandle = newActor.preferredUsername
|
||||||
|
? `${newActor.preferredUsername}@${new URL(newActorUrl).hostname}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!newInbox) {
|
||||||
|
console.error('[Inbox] New actor has no inbox');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each follow relationship and send new Follow activities
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const { createFollowActivity } = await import('./activities');
|
||||||
|
const { deliverActivity } = await import('./outbox');
|
||||||
|
|
||||||
|
for (const follow of affectedFollows) {
|
||||||
|
try {
|
||||||
|
// Get the local user who was following
|
||||||
|
const localUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, follow.followerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!localUser || !localUser.privateKeyEncrypted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the remoteFollows record with new actor info
|
||||||
|
const newActivityId = crypto.randomUUID();
|
||||||
|
await db.update(remoteFollows)
|
||||||
|
.set({
|
||||||
|
targetActorUrl: newActorUrl,
|
||||||
|
targetHandle: newHandle || follow.targetHandle,
|
||||||
|
inboxUrl: newInbox,
|
||||||
|
activityId: newActivityId,
|
||||||
|
displayName: newActor.name || follow.displayName,
|
||||||
|
avatarUrl: newActor.icon?.url || newActor.icon || follow.avatarUrl,
|
||||||
|
})
|
||||||
|
.where(eq(remoteFollows.id, follow.id));
|
||||||
|
|
||||||
|
// Send a Follow activity to the new actor
|
||||||
|
const followActivity = createFollowActivity(
|
||||||
|
localUser,
|
||||||
|
newActorUrl,
|
||||||
|
nodeDomain,
|
||||||
|
newActivityId
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyId = `https://${nodeDomain}/users/${localUser.handle}#main-key`;
|
||||||
|
await deliverActivity(followActivity, newInbox, localUser.privateKeyEncrypted, keyId);
|
||||||
|
|
||||||
|
console.log(`[Inbox] Auto-migrated @${localUser.handle}'s follow to ${newActorUrl}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Inbox] Error migrating follow ${follow.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Inbox] DID-based migration complete. ${affectedFollows.length} followers migrated.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Inbox] Error during DID-based migration:', error);
|
||||||
|
}
|
||||||
} 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
|
||||||
|
|||||||
@@ -95,11 +95,31 @@ export async function deliverToFollowers(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get followers' inboxes for delivery
|
* Get followers' inboxes for delivery
|
||||||
* This would query the database for follower inbox URLs
|
* Queries the remoteFollowers table for inbox URLs of remote users following this user
|
||||||
*/
|
*/
|
||||||
export async function getFollowerInboxes(userId: string): Promise<string[]> {
|
export async function getFollowerInboxes(userId: string): Promise<string[]> {
|
||||||
// TODO: Query database for followers and their inbox URLs
|
try {
|
||||||
// For local followers: use their local inbox
|
const { db, remoteFollowers } = await import('@/db');
|
||||||
// For remote followers: use their remote inbox (stored when they followed)
|
const { eq } = await import('drizzle-orm');
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.warn('[Outbox] Database not available for follower query');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all remote followers of this user
|
||||||
|
const followers = await db.query.remoteFollowers.findMany({
|
||||||
|
where: eq(remoteFollowers.userId, userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefer shared inbox when available (more efficient)
|
||||||
|
const inboxes = followers.map(f => f.sharedInboxUrl || f.inboxUrl);
|
||||||
|
|
||||||
|
// Deduplicate (shared inboxes may appear multiple times)
|
||||||
|
return [...new Set(inboxes)];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Outbox] Error fetching follower inboxes:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user