Many improvments and bug fixes

This commit is contained in:
AskIt
2026-01-23 07:09:35 +01:00
parent 4b5a0a85f4
commit cfb558fff1
14 changed files with 906 additions and 536 deletions
+8 -2
View File
@@ -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>
+25 -4
View File
@@ -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,
+29 -1
View File
@@ -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) {
+30 -1
View File
@@ -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) {
+27 -1
View File
@@ -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,
})); }));
+9
View File
@@ -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
View File
@@ -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>
+8 -292
View File
@@ -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>
); );
} }
+2 -2
View File
@@ -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>
+4
View File
@@ -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
View File
@@ -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
+24 -4
View File
@@ -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 [];
}
}