From cfb558fff1c79659eafef3b3c7a478880e08f49e Mon Sep 17 00:00:00 2001 From: AskIt Date: Fri, 23 Jan 2026 07:09:35 +0100 Subject: [PATCH] Many improvments and bug fixes --- src/app/[handle]/page.tsx | 10 +- src/app/api/account/export/route.ts | 33 +- src/app/api/posts/[id]/like/route.ts | 30 +- src/app/api/posts/[id]/repost/route.ts | 31 +- src/app/api/posts/route.ts | 28 +- src/app/api/users/[handle]/follow/route.ts | 20 + src/app/api/users/[handle]/following/route.ts | 6 +- src/app/globals.css | 9 + src/app/login/page.tsx | 462 ++++++++++++----- src/app/settings/migration/page.tsx | 470 ++++-------------- src/app/settings/page.tsx | 4 +- src/db/schema.ts | 4 + src/lib/activitypub/inbox.ts | 305 +++++++++++- src/lib/activitypub/outbox.ts | 30 +- 14 files changed, 906 insertions(+), 536 deletions(-) diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index abc4d4c..a773d91 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -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 ( @@ -31,8 +37,8 @@ function UserRow({ user }: { user: UserSummary }) {
{user.displayName || user.handle}
{formatFullHandle(user.handle)}
- {user.bio && ( -
{user.bio}
+ {user.bio && stripHtml(user.bio) && ( +
{stripHtml(user.bio)}
)}
diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts index 022db8a..7c3f0e4 100644 --- a/src/app/api/account/export/route.ts +++ b/src/app/api/account/export/route.ts @@ -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, diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 79f2682..1b9e8d7 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -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) { diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts index 3486b9c..5a9d8d4 100644 --- a/src/app/api/posts/[id]/repost/route.ts +++ b/src/app/api/posts/[id]/repost/route.ts @@ -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) { diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 710d535..fc7eae6 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -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) { diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index 8af8070..9ff380d 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -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 diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts index b00d26b..f454ef2 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -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, })); diff --git a/src/app/globals.css b/src/app/globals.css index c4eceb1..9181840 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 85b515d..6db8cd9 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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(null); + const [importPassword, setImportPassword] = useState(''); + const [importHandle, setImportHandle] = useState(''); + const [acceptedCompliance, setAcceptedCompliance] = useState(false); + const [importSuccess, setImportSuccess] = useState(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' }}> + {/* Form */} -
- {error && ( -
- {error} -
- )} + {mode !== 'import' ? ( + + {error && ( +
+ {error} +
+ )} - {mode === 'register' && ( - <> -
- -
- @ + {mode === 'register' && ( + <> +
+ +
+ @ + setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} + style={{ paddingLeft: '28px' }} + placeholder="yourhandle" + required + minLength={3} + maxLength={20} + /> +
+
+ + 3-20 characters, alphanumeric and underscores + + {handleStatus === 'checking' && ( + Checking... + )} + {handleStatus === 'available' && ( + Available + )} + {handleStatus === 'taken' && ( + Taken + )} +
+
+ +
+ 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" />
-
- - 3-20 characters, alphanumeric and underscores - - {handleStatus === 'checking' && ( - Checking... - )} - {handleStatus === 'available' && ( - Available - )} - {handleStatus === 'taken' && ( - Taken - )} -
-
+ + )} -
- - setDisplayName(e.target.value)} - placeholder="Your Name" - /> -
- - )} +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + /> +
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - required - /> -
- -
- - setPassword(e.target.value)} - placeholder="••••••••" - required - minLength={8} - /> -
- - {mode === 'register' && (
setConfirmPassword(e.target.value)} + value={password} + onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" required minLength={8} />
- )} - - + {mode === 'register' && ( +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + minLength={8} + /> +
+ )} + + + + ) : ( +
+ {error && ( +
+ {error} +
+ )} + + {importSuccess && ( +
+ {importSuccess} Redirecting... +
+ )} + +
+ +
+ setImportFile(e.target.files?.[0] || null)} + style={{ display: 'none' }} + /> + +
+
+ +
+ + setImportPassword(e.target.value)} + placeholder="Enter the password for this account" + required + /> +
+ +
+ +
+ @ + setImportHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} + style={{ paddingLeft: '28px' }} + placeholder="yourhandle" + required + minLength={3} + maxLength={20} + /> +
+
+ + 3-20 chars + + {handleStatus === 'checking' && ( + Checking... + )} + {handleStatus === 'available' && ( + Available + )} + {handleStatus === 'taken' && ( + Taken + )} +
+
+ +
+ +
+ + +
+ )}

← Back to home diff --git a/src/app/settings/migration/page.tsx b/src/app/settings/migration/page.tsx index 7b511ab..0cbd963 100644 --- a/src/app/settings/migration/page.tsx +++ b/src/app/settings/migration/page.tsx @@ -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(null); - const [exportData, setExportData] = useState(null); const [exportStats, setExportStats] = useState(null); - // Import state - const [importFile, setImportFile] = useState(null); - const [importPassword, setImportPassword] = useState(''); - const [importHandle, setImportHandle] = useState(''); - const [acceptedCompliance, setAcceptedCompliance] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [importError, setImportError] = useState(null); - const [importSuccess, setImportSuccess] = useState(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 (
-

Account Migration

+

Export Account

-

Please log in to export your account, or switch to Import to migrate an account here.

- +

Please log in to export your account.

+ Log In
); @@ -189,298 +99,104 @@ export default function MigrationPage() {
-

Account Migration

+

Export Account

- Move your identity between Synapsis nodes + Download a backup of your identity and content

- {/* Tabs */} -
+
+

+ Download Your Data +

+ +

+ 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. +

+ +
+
+ Your export will include: +
+
    +
  • Your DID (Decentralized Identifier)
  • +
  • Your cryptographic keys (encrypted with your password)
  • +
  • Your profile information
  • +
  • All your posts
  • +
  • Your following list
  • +
+
+ +
+ + setExportPassword(e.target.value)} + placeholder="Enter your password" + /> +
+ + {exportError && ( +
+ {exportError} +
+ )} + + {exportStats && ( +
+ Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references. +
+ )} + - + +
+
+ Security Warning +
+

+ 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. +

+
- - {/* Export Tab */} - {activeTab === 'export' && user && ( -
-

- Export Your Account -

- -

- Download a complete backup of your account including your identity, posts, and media. - You can use this file to migrate to another Synapsis node. -

- -
-
- Your export will include: -
-
    -
  • Your DID (Decentralized Identifier)
  • -
  • Your cryptographic keys (encrypted with your password)
  • -
  • Your profile information
  • -
  • All your posts
  • -
  • Your following list
  • -
-
- -
- - setExportPassword(e.target.value)} - placeholder="Enter your password" - /> -
- - {exportError && ( -
- {exportError} -
- )} - - {exportStats && ( -
- Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references. -
- )} - - - -
-
- Security Warning -
-

- 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. -

-
-
- )} - - {/* Import Tab */} - {activeTab === 'import' && ( -
-

- Import an Account -

- -

- Migrate an account from another Synapsis node. Your DID will be preserved, - and followers on other Synapsis nodes will be automatically migrated. -

- -
- -
- setImportFile(e.target.files?.[0] || null)} - style={{ display: 'none' }} - /> - -
-
- -
- - setImportPassword(e.target.value)} - placeholder="Enter the password for this account" - /> -
- -
- -
- @ - setImportHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} - style={{ paddingLeft: '28px' }} - placeholder="yourhandle" - required - minLength={3} - maxLength={20} - /> -
-
- - 3-20 characters, alphanumeric and underscores - - {handleStatus === 'checking' && ( - Checking... - )} - {handleStatus === 'available' && ( - Available - )} - {handleStatus === 'taken' && ( - Taken - )} -
-
- - {/* Compliance Agreement */} -
- -
- - {importError && ( -
- {importError} -
- )} - - {importSuccess && ( -
- {importSuccess} -
- )} - - -
- )}
); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 4dd18a4..60c7227 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -34,10 +34,10 @@ export default function SettingsPage() { }}>
- Account Migration + Export Account
- Export your account or import from another Synapsis node + Download a backup of your account and content
diff --git a/src/db/schema.ts b/src/db/schema.ts index a7e9669..68964e6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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), diff --git a/src/lib/activitypub/inbox.ts b/src/lib/activitypub/inbox.ts index a5e0b49..ce53a7f 100644 --- a/src/lib/activitypub/inbox.ts +++ b/src/lib/activitypub/inbox.ts @@ -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 diff --git a/src/lib/activitypub/outbox.ts b/src/lib/activitypub/outbox.ts index 9e0e99c..9e53981 100644 --- a/src/lib/activitypub/outbox.ts +++ b/src/lib/activitypub/outbox.ts @@ -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 { - // 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 []; + } } +