Many improvments and bug fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 => ({
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+232
-4
@@ -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,9 +216,26 @@ 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 */}
|
||||
{mode !== 'import' ? (
|
||||
<form onSubmit={handleSubmit} className="card" style={{ padding: '24px' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
@@ -295,6 +369,160 @@ export default function LoginPage() {
|
||||
{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>
|
||||
|
||||
@@ -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,57 +99,21 @@ 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)' }}>
|
||||
<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' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
|
||||
Export Your Account
|
||||
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.
|
||||
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
@@ -323,164 +197,6 @@ export default function MigrationPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
+283
-20
@@ -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,18 +107,102 @@ 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' };
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Follow activities
|
||||
*/
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user