Migrate user/profile URLs to /u/[handle] and remove legacy scripts

All user and post profile URLs have been updated from /[handle] to /u/[handle] for consistency. Added a redirect page for /posts/[id] to the canonical /u/[handle]/posts/[id] route. Deprecated and removed legacy scripts and documentation related to chat and federation endpoints. Push-based federation endpoints are now disabled in favor of real-time pull-based federation.
This commit is contained in:
Christopher
2026-01-27 01:26:41 -08:00
parent 44610157a1
commit a68aee8758
20 changed files with 85 additions and 573 deletions
+1
View File
@@ -102,6 +102,7 @@ export async function GET(request: Request) {
post: row.postId ? {
id: row.postId,
content: row.postContent,
authorHandle: row.actorHandle, // The actor is the post author for likes/reposts
} : null,
};
});
+5 -1
View File
@@ -115,7 +115,10 @@ export async function POST(request: Request) {
}
}
// If this is a reply to a swarm post, deliver it to the origin node
// DEPRECATED: Push-based federation disabled
// Swarm now uses real-time pull-based federation
// Replies are fetched in real-time from the origin node
/*
if (data.swarmReplyTo) {
(async () => {
try {
@@ -153,6 +156,7 @@ export async function POST(request: Request) {
}
})();
}
*/
// Handle local mentions (create notifications for users on this node)
(async () => {
+6 -108
View File
@@ -43,114 +43,12 @@ const swarmPostSchema = z.object({
/**
* POST /api/swarm/inbox
*
* Receives a post from another swarm node.
* Stores it for local users who follow the author.
* DEPRECATED: This endpoint is disabled.
* We now use real-time pull-based federation via /api/swarm/timeline
* instead of push-based caching.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmPostSchema.parse(body);
// Construct the swarm post ID
const swarmPostId = `swarm:${data.nodeDomain}:${data.post.id}`;
// Check if we already have this post
const existingPost = await db.query.posts.findFirst({
where: eq(posts.apId, swarmPostId),
});
if (existingPost) {
return NextResponse.json({
success: true,
message: 'Post already exists',
});
}
// Check if anyone on this node follows the author
const authorActorUrl = `swarm://${data.nodeDomain}/${data.author.handle}`;
const hasFollowers = await db.query.remoteFollowers.findFirst({
where: eq(remoteFollowers.actorUrl, authorActorUrl),
});
// Even if no one follows, we might want to cache for timeline
// For now, only store if someone follows
if (!hasFollowers) {
return NextResponse.json({
success: true,
message: 'No local followers',
});
}
// Get or create placeholder user for the remote author
const remoteHandle = `${data.author.handle}@${data.nodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.nodeDomain}:${data.author.handle}`,
handle: remoteHandle,
displayName: data.author.displayName,
avatarUrl: data.author.avatarUrl || null,
isNsfw: data.author.isNsfw,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
} else {
// Update profile info if changed
await db.update(users)
.set({
displayName: data.author.displayName,
avatarUrl: data.author.avatarUrl || remoteUser.avatarUrl,
isNsfw: data.author.isNsfw,
})
.where(eq(users.id, remoteUser.id));
}
// Create the post
const [newPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.post.content,
isNsfw: data.post.isNsfw || data.author.isNsfw,
apId: swarmPostId,
apUrl: `https://${data.nodeDomain}/${data.author.handle}/posts/${data.post.id}`,
createdAt: new Date(data.post.createdAt),
linkPreviewUrl: data.post.linkPreviewUrl || null,
linkPreviewTitle: data.post.linkPreviewTitle || null,
linkPreviewDescription: data.post.linkPreviewDescription || null,
linkPreviewImage: data.post.linkPreviewImage || null,
}).returning();
// Store media attachments
if (data.post.media && data.post.media.length > 0) {
for (const m of data.post.media) {
await db.insert(media).values({
userId: remoteUser.id,
postId: newPost.id,
url: m.url,
mimeType: m.mimeType || null,
altText: m.altText || null,
});
}
}
console.log(`[Swarm] Received post from ${remoteHandle}: ${newPost.id}`);
return NextResponse.json({
success: true,
message: 'Post received',
postId: newPost.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Inbox error:', error);
return NextResponse.json({ error: 'Failed to process post' }, { status: 500 });
}
return NextResponse.json({
error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.',
}, { status: 410 }); // 410 Gone
}
+5 -78
View File
@@ -30,86 +30,13 @@ const swarmReplySchema = z.object({
/**
* POST /api/swarm/replies
*
* Receives a reply from another node in the swarm.
* The reply is stored as a remote reply linked to the local post.
* DEPRECATED: This endpoint is disabled.
* We now use real-time pull-based federation instead of push-based caching.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmReplySchema.parse(body);
// Verify the target post exists on this node
const targetPost = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!targetPost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Check if we already have this reply (by swarm ID)
const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`;
const existingReply = await db.query.posts.findFirst({
where: eq(posts.apId, swarmReplyId),
});
if (existingReply) {
return NextResponse.json({ success: true, message: 'Reply already exists' });
}
// We need a system user to attribute swarm replies to
// For now, we'll store them with metadata in the apId/apUrl fields
// and create a virtual representation
// Get or create a placeholder user for this remote author
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`),
});
if (!remoteUser) {
// Create a placeholder user for the remote author
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`,
handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`,
displayName: data.reply.author.displayName,
avatarUrl: data.reply.author.avatarUrl || null,
publicKey: 'swarm-remote-user', // Placeholder
}).returning();
remoteUser = newUser;
}
// Create the reply post
const [replyPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.reply.content,
replyToId: data.postId,
apId: swarmReplyId,
apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`,
createdAt: new Date(data.reply.createdAt),
}).returning();
// Update the parent post's reply count
await db.update(posts)
.set({ repliesCount: targetPost.repliesCount + 1 })
.where(eq(posts.id, data.postId));
console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`);
return NextResponse.json({
success: true,
replyId: replyPost.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Reply error:', error);
return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 });
}
return NextResponse.json({
error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.',
}, { status: 410 }); // 410 Gone
}
/**
+13 -7
View File
@@ -489,15 +489,21 @@ export default function ChatPage() {
marginLeft: msg.isSentByMe ? 'auto' : '0',
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
}}>
{!msg.isSentByMe && (
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{msg.senderAvatarUrl ? (
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{msg.isSentByMe ? (
user.avatarUrl ? (
<img src={user.avatarUrl} alt="" />
) : (
user.displayName[0]
)
) : (
msg.senderAvatarUrl ? (
<img src={msg.senderAvatarUrl} alt="" />
) : (
msg.senderDisplayName?.[0]
)}
</div>
)}
)
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
<div style={{
@@ -682,7 +688,7 @@ export default function ChatPage() {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{conv.lastMessagePreview}
{conv.lastMessagePreview === 'New message' ? 'Encrypted Message' : conv.lastMessagePreview}
</div>
</div>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ interface User {
function UserCard({ user }: { user: User }) {
return (
<Link href={`/${user.handle}`} className="user-card">
<Link href={`/u/${user.handle}`} className="user-card">
<div className="avatar">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} />
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
export default function PostRedirect() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
useEffect(() => {
const fetchAndRedirect = async () => {
try {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) {
router.push('/');
return;
}
const data = await res.json();
router.push(`/u/${data.post.author.handle}/posts/${id}`);
} catch {
router.push('/');
}
};
fetchAndRedirect();
}, [id, router]);
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--foreground-tertiary)',
}}>
Loading...
</div>
);
}
+2 -2
View File
@@ -111,7 +111,7 @@ export default function BotsPage() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/${bot.handle}`}
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
@@ -147,7 +147,7 @@ export default function BotsPage() {
)}
</div>
<Link
href={`/${bot.handle}`}
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@@ -35,7 +35,7 @@ const stripHtml = (html: string | null | undefined): string | null => {
function UserRow({ user }: { user: UserSummary }) {
return (
<Link href={`/${user.handle}`} className="user-row">
<Link href={`/u/${user.handle}`} className="user-row">
<div className="avatar">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
@@ -215,7 +215,7 @@ export default function ProfilePage() {
const handleComment = (post: Post) => {
// Navigation is handled by the PostCard overlay,
// but we can also use router.push if they explicitly click the comment button.
router.push(`/${post.author.handle}/posts/${post.id}`);
router.push(`/u/${post.author.handle}/posts/${post.id}`);
};
const handleDelete = (postId: string) => {
@@ -624,7 +624,7 @@ export default function ProfilePage() {
<>
{' · Managed by '}
<Link
href={`/${(user as any).botOwner.handle}`}
href={`/u/${(user as any).botOwner.handle}`}
style={{ color: 'var(--accent)', fontWeight: 500 }}
>
@{(user as any).botOwner.handle}
@@ -85,7 +85,7 @@ export default function PostDetailPage() {
const handleDelete = (postId: string) => {
if (postId === id) {
router.push(`/${handle}`);
router.push(`/u/${handle}`);
} else {
setReplies(prev => prev.filter(r => r.id !== postId));
if (post) {
+6 -6
View File
@@ -248,7 +248,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
}
};
const postUrl = `/${post.author.handle}/posts/${post.id}`;
const postUrl = `/u/${post.author.handle}/posts/${post.id}`;
// Get the full handle for profile links (includes domain for remote users)
const getProfileHandle = () => {
@@ -384,7 +384,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
return (
<article className="post thread-parent">
<div className="post-header">
<Link href={`/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
@@ -394,7 +394,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
</div>
</Link>
<div className="post-author">
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle}
</Link>
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span>
@@ -427,7 +427,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
<div className="post-header">
<Link href={`/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
@@ -438,7 +438,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
</Link>
<div className="post-author">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle}
</Link>
{(post.bot || post.author.isBot) && (
@@ -585,7 +585,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
{effectiveReplyTo && !showThread && (
<div className="post-reply-to">
Replying to <Link href={`/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link>
Replying to <Link href={`/u/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link>
</div>
)}
+1 -1
View File
@@ -147,7 +147,7 @@ export function RightSidebar() {
{nodeInfo.admins.map((admin) => (
<Link
key={admin.handle}
href={`/${admin.handle}`}
href={`/u/${admin.handle}`}
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
>
<img
+1 -1
View File
@@ -144,7 +144,7 @@ export function Sidebar() {
</Link>
)}
{user ? (
<Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}>
<Link href={`/u/${user.handle}`} className={`nav-item ${pathname === '/u/' + user.handle ? 'active' : ''}`}>
<UserIcon />
<span>Profile</span>
</Link>