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;
}
// 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 }) {
return (
<Link href={`/${user.handle}`} className="user-row">
@@ -31,8 +37,8 @@ function UserRow({ user }: { user: UserSummary }) {
<div className="user-row-content">
<div style={{ fontWeight: 600 }}>{user.displayName || user.handle}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div>
{user.bio && (
<div className="user-row-bio">{user.bio}</div>
{user.bio && stripHtml(user.bio) && (
<div className="user-row-bio">{stripHtml(user.bio)}</div>
)}
</div>
</Link>
+27 -6
View File
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
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 * as crypto from 'crypto';
@@ -45,6 +45,10 @@ interface ExportPost {
interface ExportFollowing {
actorUrl: 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)],
});
// Fetch user's following list
// Fetch user's following list (local and remote)
const userFollowing = await db.query.follows.findMany({
where: eq(follows.followerId, user.id),
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
const exportPosts: ExportPost[] = userPosts.map(post => ({
id: post.id,
@@ -141,10 +149,23 @@ export async function POST(req: NextRequest) {
})),
}));
const exportFollowing: ExportFollowing[] = userFollowing.map(f => ({
actorUrl: `https://${nodeDomain}/users/${f.following.handle}`,
handle: f.following.handle,
}));
const exportFollowing: ExportFollowing[] = [
// Local follows
...userFollowing.map(f => ({
actorUrl: `https://${nodeDomain}/users/${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 = {
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 });
} 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 });
} 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 } });
} catch (error) {
@@ -19,6 +19,12 @@ const parseRemoteHandle = (handle: string) => {
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
export async function GET(request: Request, context: RouteContext) {
try {
@@ -126,12 +132,26 @@ export async function POST(request: Request, context: RouteContext) {
if (!result.success) {
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({
followerId: currentUser.id,
targetHandle,
targetActorUrl: remoteProfile.id,
inboxUrl: targetInbox,
activityId,
displayName: remoteProfile.name || null,
bio: stripHtml(remoteProfile.summary),
avatarUrl,
});
// Update the user's following count
@@ -55,9 +55,9 @@ export async function GET(request: Request, context: RouteContext) {
const remoteFollowing = userRemoteFollowing.map(f => ({
id: f.targetActorUrl,
handle: f.targetHandle,
displayName: f.targetHandle.split('@')[0], // Use username part as display name
avatarUrl: null,
bio: null,
displayName: f.displayName || f.targetHandle.split('@')[0], // Use stored display name or username part
avatarUrl: f.avatarUrl,
bio: f.bio,
isRemote: true,
}));
+9
View File
@@ -204,6 +204,15 @@ a.btn-primary:visited {
object-fit: cover;
}
.user-row .avatar {
flex-shrink: 0;
}
.user-row .avatar img {
object-fit: contain;
background: var(--background-tertiary);
}
.avatar-sm {
width: 32px;
height: 32px;
+345 -117
View File
@@ -4,10 +4,11 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { SynapsisLogo } from '@/components/Icons';
import { TriangleAlert, ShieldAlert } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -18,6 +19,13 @@ export default function LoginPage() {
const [nodeInfo, setNodeInfo] = useState({ name: '', description: '' });
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
useEffect(() => {
fetch('/api/node')
@@ -33,7 +41,8 @@ export default function LoginPage() {
// Handle availability check
useEffect(() => {
if (mode !== 'register' || !handle || handle.length < 3) {
const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : '');
if (!checkHandle || checkHandle.length < 3) {
setHandleStatus('idle');
return;
}
@@ -41,7 +50,7 @@ export default function LoginPage() {
const timer = setTimeout(async () => {
setHandleStatus('checking');
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();
if (data.available) {
setHandleStatus('available');
@@ -54,7 +63,54 @@ export default function LoginPage() {
}, 500);
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) => {
e.preventDefault();
@@ -126,6 +182,7 @@ export default function LoginPage() {
background: 'var(--background-secondary)',
borderRadius: 'var(--radius-md)',
padding: '4px',
gap: '4px'
}}>
<button
onClick={() => setMode('login')}
@@ -159,142 +216,313 @@ export default function LoginPage() {
>
Register
</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>
{/* Form */}
<form onSubmit={handleSubmit} 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>
)}
{mode !== 'import' ? (
<form onSubmit={handleSubmit} 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>
)}
{mode === 'register' && (
<>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Handle
</label>
<div style={{ position: 'relative' }}>
<span style={{
position: 'absolute',
left: '12px',
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--foreground-tertiary)',
}}>@</span>
{mode === 'register' && (
<>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Handle
</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={handle}
onChange={(e) => setHandle(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>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Display Name
</label>
<input
type="text"
className="input"
value={handle}
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
style={{ paddingLeft: '28px' }}
placeholder="yourhandle"
required
minLength={3}
maxLength={20}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Name"
/>
</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>
</>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Display Name
</label>
<input
type="text"
className="input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Name"
/>
</div>
</>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Email
</label>
<input
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Email
</label>
<input
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Password
</label>
<input
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
{mode === 'register' && (
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Confirm Password
Password
</label>
<input
type="password"
className="input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
)}
<button
type="submit"
className="btn btn-primary btn-lg"
style={{ width: '100%' }}
disabled={loading}
>
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
</button>
</form>
{mode === 'register' && (
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Confirm Password
</label>
<input
type="password"
className="input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
)}
<button
type="submit"
className="btn btn-primary btn-lg"
style={{ width: '100%' }}
disabled={loading}
>
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
</button>
</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' }}>
<Link href="/"> Back to home</Link>
+93 -377
View File
@@ -14,50 +14,13 @@ interface ExportStats {
export default function MigrationPage() {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<'export' | 'import'>('export');
// Export state
const [exportPassword, setExportPassword] = useState('');
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportData, setExportData] = useState<object | 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 () => {
if (!exportPassword) {
setExportError('Please enter your password');
@@ -80,7 +43,6 @@ export default function MigrationPage() {
throw new Error(data.error || 'Export failed');
}
setExportData(data.export);
setExportStats(data.stats);
// Trigger download
@@ -101,59 +63,7 @@ export default function MigrationPage() {
}
};
const handleImport = async () => {
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') {
if (!user) {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
@@ -166,12 +76,12 @@ export default function MigrationPage() {
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Account Migration</h1>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Export Account</h1>
</div>
</header>
<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>
<button className="btn" onClick={() => setActiveTab('import')}>Switch to Import</button>
<p style={{ marginBottom: '16px' }}>Please log in to export your account.</p>
<Link href="/login" className="btn btn-primary">Log In</Link>
</div>
</div>
);
@@ -189,298 +99,104 @@ export default function MigrationPage() {
<ArrowLeftIcon />
</Link>
<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' }}>
Move your identity between Synapsis nodes
Download a backup of your identity and content
</p>
</div>
</header>
{/* Tabs */}
<div style={{ display: 'flex', marginBottom: '24px', borderBottom: '1px solid var(--border)' }}>
<div className="card" style={{ padding: '24px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
Download Your Data
</h2>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
Download a complete backup of your account including your identity, posts, and media.
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
</p>
<div style={{
background: 'var(--background-tertiary)',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
}}>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
Your export will include:
</div>
<ul style={{
listStyle: 'disc',
paddingLeft: '20px',
color: 'var(--foreground-secondary)',
fontSize: '14px',
lineHeight: 1.6,
}}>
<li>Your DID (Decentralized Identifier)</li>
<li>Your cryptographic keys (encrypted with your password)</li>
<li>Your profile information</li>
<li>All your posts</li>
<li>Your following list</li>
</ul>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Confirm your password
</label>
<input
type="password"
className="input"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
/>
</div>
{exportError && (
<div style={{ color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}>
{exportError}
</div>
)}
{exportStats && (
<div style={{
background: 'var(--success)',
color: '#000',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
}}>
Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references.
</div>
)}
<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',
}}
className="btn btn-primary"
onClick={handleExport}
disabled={isExporting || !exportPassword}
style={{ width: '100%' }}
>
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
{isExporting ? 'Exporting...' : 'Download Export File'}
</button>
<div style={{
marginTop: '20px',
padding: '16px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '8px',
}}>
<div style={{ fontWeight: 600, color: 'var(--error)', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<ShieldAlert size={18} /> Security Warning
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
The export file contains your encrypted private key. Keep this file secure
and never share it with anyone. Anyone with this file and your password
can access your account.
</p>
</div>
</div>
{/* Export Tab */}
{activeTab === 'export' && user && (
<div className="card" style={{ padding: '24px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
Export Your Account
</h2>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
Download a complete backup of your account including your identity, posts, and media.
You can use this file to migrate to another Synapsis node.
</p>
<div style={{
background: 'var(--background-tertiary)',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
}}>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
Your export will include:
</div>
<ul style={{
listStyle: 'disc',
paddingLeft: '20px',
color: 'var(--foreground-secondary)',
fontSize: '14px',
lineHeight: 1.6,
}}>
<li>Your DID (Decentralized Identifier)</li>
<li>Your cryptographic keys (encrypted with your password)</li>
<li>Your profile information</li>
<li>All your posts</li>
<li>Your following list</li>
</ul>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Confirm your password
</label>
<input
type="password"
className="input"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
/>
</div>
{exportError && (
<div style={{ color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}>
{exportError}
</div>
)}
{exportStats && (
<div style={{
background: 'var(--success)',
color: '#000',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
}}>
Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references.
</div>
)}
<button
className="btn btn-primary"
onClick={handleExport}
disabled={isExporting || !exportPassword}
style={{ width: '100%' }}
>
{isExporting ? 'Exporting...' : 'Download Export File'}
</button>
<div style={{
marginTop: '20px',
padding: '16px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '8px',
}}>
<div style={{ fontWeight: 600, color: 'var(--error)', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<ShieldAlert size={18} /> Security Warning
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
The export file contains your encrypted private key. Keep this file secure
and never share it with anyone. Anyone with this file and your password
can access your account.
</p>
</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>
);
}
+2 -2
View File
@@ -34,10 +34,10 @@ export default function SettingsPage() {
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} />
Account Migration
Export Account
</div>
<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>
</Link>
+4
View File
@@ -199,6 +199,10 @@ export const remoteFollows = pgTable('remote_follows', {
targetActorUrl: text('target_actor_url').notNull(),
inboxUrl: text('inbox_url').notNull(),
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(),
}, (table) => [
index('remote_follows_follower_idx').on(table.followerId),
+284 -21
View File
@@ -4,7 +4,7 @@
* 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 { verifySignature, fetchActorPublicKey } from './signatures';
import { createAcceptActivity } from './activities';
@@ -107,16 +107,100 @@ export async function processIncomingActivity(
* Handle Create activities (new posts)
*/
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') {
return { success: true }; // We only handle Notes for now
}
// TODO: Store remote posts in database for caching/display
console.log('[Inbox] Received remote post:', object.id);
if (!object.id || !object.attributedTo) {
console.warn('[Inbox] Create activity missing id or attributedTo');
return { success: false, error: 'Missing required fields' };
}
return { success: true };
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 };
}
// 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' };
}
}
/**
@@ -244,9 +328,27 @@ async function handleLike(activity: IncomingActivity): Promise<{ success: boolea
return { success: false, error: 'Invalid like target' };
}
// TODO: Update like count on local post
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 };
}
@@ -260,9 +362,27 @@ async function handleAnnounce(activity: IncomingActivity): Promise<{ success: bo
return { success: false, error: 'Invalid announce target' };
}
// TODO: Update repost count on local post
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 };
}
@@ -330,7 +450,36 @@ async function handleUndo(
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
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 };
}
@@ -341,7 +490,51 @@ async function handleDelete(activity: IncomingActivity): Promise<{ success: bool
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
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 };
}
@@ -379,22 +572,92 @@ async function handleMove(activity: IncomingActivity): Promise<{ success: boolea
if (did) {
console.log(`[Inbox] Move includes DID: ${did} - attempting automatic migration`);
// Find any local follows that match this DID
// This would require querying by the remote user's DID
// For now, we'll log the DID and handle it
try {
// Find all local users following the old actor URL
const affectedFollows = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.targetActorUrl, oldActorUrl),
});
// In a full implementation, we would:
// 1. Find all local users following the old actor URL
// 2. Update their follow relationship to point to the new actor URL
// 3. Automatically send a Follow to the new actor
if (affectedFollows.length === 0) {
console.log('[Inbox] No local users following the migrating account');
return { success: true };
}
// For Synapsis-to-Synapsis migrations, we can auto-follow
// because we trust the DID verification
console.log(`[Inbox] Found ${affectedFollows.length} local users to migrate`);
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
// await migrateFollowersByDid(did, oldActorUrl, newActorUrl);
if (!newActorResponse.ok) {
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 {
// Standard Fediverse Move - just log it
// Users will need to manually re-follow
+25 -5
View File
@@ -95,11 +95,31 @@ export async function deliverToFollowers(
/**
* 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[]> {
// TODO: Query database for followers and their inbox URLs
// For local followers: use their local inbox
// For remote followers: use their remote inbox (stored when they followed)
return [];
try {
const { db, remoteFollowers } = await import('@/db');
const { eq } = await import('drizzle-orm');
if (!db) {
console.warn('[Outbox] Database not available for follower query');
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 [];
}
}