Normalize same-node qualified handles across profile and user interaction routes while preserving remote federation.
Hop-State: A_06FPF70QSGVCEXKW49YZXJ0 Hop-Proposal: R_06FPF6ZE99FWX3DJRDCC6JR Hop-Task: T_06FPF593K79EBFYJ4X58SQG Hop-Attempt: AT_06FPF593K46XZT11Q9CC86G
This commit is contained in:
@@ -3,6 +3,7 @@ import { db } from '@/db';
|
|||||||
import { users, blocks, follows } from '@/db/schema';
|
import { users, blocks, follows } from '@/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -11,9 +12,10 @@ export async function GET(req: NextRequest, context: RouteContext) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await requireAuth();
|
const currentUser = await requireAuth();
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
|
const targetHandle = resolveUserHandle(handle).canonicalHandle;
|
||||||
|
|
||||||
const targetUser = await db.query.users.findFirst({
|
const targetUser = await db.query.users.findFirst({
|
||||||
where: { handle: handle },
|
where: { handle: targetHandle },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
@@ -39,9 +41,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await requireAuth();
|
const currentUser = await requireAuth();
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
|
const targetHandle = resolveUserHandle(handle).canonicalHandle;
|
||||||
|
|
||||||
const targetUser = await db.query.users.findFirst({
|
const targetUser = await db.query.users.findFirst({
|
||||||
where: { handle: handle },
|
where: { handle: targetHandle },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
@@ -96,9 +99,10 @@ export async function DELETE(req: NextRequest, context: RouteContext) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await requireAuth();
|
const currentUser = await requireAuth();
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
|
const targetHandle = resolveUserHandle(handle).canonicalHandle;
|
||||||
|
|
||||||
const targetUser = await db.query.users.findFirst({
|
const targetUser = await db.query.users.findFirst({
|
||||||
where: { handle: handle },
|
where: { handle: targetHandle },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
|
|||||||
@@ -7,25 +7,18 @@ import { requireSignedAction } from '@/lib/auth/verify-signature';
|
|||||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
import { buildNotificationTarget } from '@/lib/notifications';
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
const parseRemoteHandle = (handle: string) => {
|
|
||||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
|
||||||
const parts = clean.split('@').filter(Boolean);
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return { handle: parts[0], domain: parts[1] };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check follow status
|
// Check follow status
|
||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const currentUser = await requireAuth();
|
const currentUser = await requireAuth();
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
const remote = parseRemoteHandle(handle);
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
|
const remote = resolvedHandle.remote;
|
||||||
|
|
||||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||||
@@ -88,8 +81,9 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
// Let's assume the client sends targetHandle in 'data' of signed action to be secure.
|
// Let's assume the client sends targetHandle in 'data' of signed action to be secure.
|
||||||
|
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
const remote = parseRemoteHandle(handle);
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
|
const remote = resolvedHandle.remote;
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
|
|
||||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||||
@@ -254,8 +248,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
|||||||
const currentUser = await requireSignedAction(signedAction);
|
const currentUser = await requireSignedAction(signedAction);
|
||||||
|
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
const remote = parseRemoteHandle(handle);
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
|
const remote = resolvedHandle.remote;
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
|
|
||||||
if (remote) {
|
if (remote) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { db, follows, users, remoteFollowers } from '@/db';
|
import { db, follows, users, remoteFollowers } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -26,21 +27,22 @@ const fetchSwarmFollowers = async (handle: string, domain: string, limit: number
|
|||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
// Check if this is a remote user
|
// Check if this is a remote user
|
||||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
const remote = resolvedHandle.remote;
|
||||||
|
|
||||||
if (remoteDomain) {
|
if (remote) {
|
||||||
// Fetch from remote swarm node
|
// Fetch from remote swarm node
|
||||||
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit);
|
const swarmData = await fetchSwarmFollowers(remote.handle, remote.domain, limit);
|
||||||
if (swarmData?.followers) {
|
if (swarmData?.followers) {
|
||||||
// Transform to include full handles for local users on that node
|
// Transform to include full handles for local users on that node
|
||||||
const followers = swarmData.followers.map((f: any) => ({
|
const followers = swarmData.followers.map((f: any) => ({
|
||||||
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||||
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||||
displayName: f.displayName,
|
displayName: f.displayName,
|
||||||
avatarUrl: f.avatarUrl,
|
avatarUrl: f.avatarUrl,
|
||||||
bio: f.bio,
|
bio: f.bio,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { db, follows, users, remoteFollows } from '@/db';
|
import { db, follows, users, remoteFollows } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -26,21 +27,22 @@ const fetchSwarmFollowing = async (handle: string, domain: string, limit: number
|
|||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
// Check if this is a remote user
|
// Check if this is a remote user
|
||||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
const remote = resolvedHandle.remote;
|
||||||
|
|
||||||
if (remoteDomain) {
|
if (remote) {
|
||||||
// Fetch from remote swarm node
|
// Fetch from remote swarm node
|
||||||
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
|
const swarmData = await fetchSwarmFollowing(remote.handle, remote.domain, limit);
|
||||||
if (swarmData?.following) {
|
if (swarmData?.following) {
|
||||||
// Transform to include full handles for local users on that node
|
// Transform to include full handles for local users on that node
|
||||||
const following = swarmData.following.map((f: any) => ({
|
const following = swarmData.following.map((f: any) => ({
|
||||||
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||||
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||||
displayName: f.displayName,
|
displayName: f.displayName,
|
||||||
avatarUrl: f.avatarUrl,
|
avatarUrl: f.avatarUrl,
|
||||||
bio: f.bio,
|
bio: f.bio,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { db, likes, posts, users, userSwarmLikes } from '@/db';
|
|||||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
import { getRemoteBaseUrl, mapRemoteProfilePost } from '@/lib/swarm/remote-profile-posts';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
@@ -44,10 +45,11 @@ const parseMediaJson = (mediaJson: string | null) => {
|
|||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||||
const remote = parseRemoteHandle(handle);
|
const remote = resolvedHandle.remote;
|
||||||
|
|
||||||
const fetchRemoteLikesRoute = async () => {
|
const fetchRemoteLikesRoute = async () => {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
@@ -110,7 +112,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: { handle: cleanHandle },
|
where: { handle: cleanHandle },
|
||||||
});
|
});
|
||||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
const isRemotePlaceholder = Boolean(user && remote);
|
||||||
|
|
||||||
if (!user || isRemotePlaceholder) {
|
if (!user || isRemotePlaceholder) {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
|
|||||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
import { getRemoteBaseUrl, mapRemoteProfilePost } from '@/lib/swarm/remote-profile-posts';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
const embeddedPostRelations = {
|
const embeddedPostRelations = {
|
||||||
@@ -213,12 +214,13 @@ async function populateViewerLikeState(
|
|||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||||
const cursor = searchParams.get('cursor');
|
const cursor = searchParams.get('cursor');
|
||||||
|
|
||||||
const remote = parseRemoteHandle(handle);
|
const remote = resolvedHandle.remote;
|
||||||
const fetchRemotePostsRoute = async () => {
|
const fetchRemotePostsRoute = async () => {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
return NextResponse.json({ posts: [], nextCursor: null });
|
return NextResponse.json({ posts: [], nextCursor: null });
|
||||||
@@ -303,7 +305,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: { handle: cleanHandle },
|
where: { handle: cleanHandle },
|
||||||
});
|
});
|
||||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
const isRemotePlaceholder = Boolean(user && remote);
|
||||||
|
|
||||||
if (!user || isRemotePlaceholder) {
|
if (!user || isRemotePlaceholder) {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { NextResponse } from 'next/server';
|
|||||||
import { db, likes, posts, users } from '@/db';
|
import { db, likes, posts, users } from '@/db';
|
||||||
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
|
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
import { getRemoteBaseUrl, mapRemoteProfilePost } from '@/lib/swarm/remote-profile-posts';
|
||||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
|
|
||||||
const embeddedPostRelations = {
|
const embeddedPostRelations = {
|
||||||
author: true,
|
author: true,
|
||||||
@@ -31,11 +32,12 @@ type RouteContext = { params: Promise<{ handle: string }> };
|
|||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||||
const cursor = searchParams.get('cursor');
|
const cursor = searchParams.get('cursor');
|
||||||
const remote = parseRemoteHandle(handle);
|
const remote = resolvedHandle.remote;
|
||||||
|
|
||||||
const fetchRemoteReplies = async () => {
|
const fetchRemoteReplies = async () => {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
@@ -97,7 +99,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: { handle: cleanHandle },
|
where: { handle: cleanHandle },
|
||||||
});
|
});
|
||||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
const isRemotePlaceholder = Boolean(user && remote);
|
||||||
|
|
||||||
if (!user || isRemotePlaceholder) {
|
if (!user || isRemotePlaceholder) {
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
findUser: vi.fn(),
|
||||||
|
fetchSwarmUserProfile: vi.fn(),
|
||||||
|
isSwarmNode: vi.fn(),
|
||||||
|
discoverNode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/db', () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
users: {
|
||||||
|
findFirst: mocks.findUser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {},
|
||||||
|
follows: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
getSession: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/swarm/interactions', () => ({
|
||||||
|
fetchSwarmUserProfile: mocks.fetchSwarmUserProfile,
|
||||||
|
isSwarmNode: mocks.isSwarmNode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/swarm/discovery', () => ({
|
||||||
|
discoverNode: mocks.discoverNode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GET } from './route';
|
||||||
|
|
||||||
|
const localUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
handle: 'wpb8erboy',
|
||||||
|
displayName: 'Wpb8erboy',
|
||||||
|
bio: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
headerUrl: null,
|
||||||
|
followersCount: 1,
|
||||||
|
followingCount: 2,
|
||||||
|
postsCount: 1,
|
||||||
|
createdAt: new Date('2026-07-15T19:32:12Z'),
|
||||||
|
website: null,
|
||||||
|
movedTo: null,
|
||||||
|
isBot: false,
|
||||||
|
publicKey: 'public-key',
|
||||||
|
did: 'did:key:local-user',
|
||||||
|
dmPrivacy: 'everyone',
|
||||||
|
isNsfw: true,
|
||||||
|
isSuspended: false,
|
||||||
|
botOwnerId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('user profile route', () => {
|
||||||
|
const previousDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NEXT_PUBLIC_NODE_DOMAIN = 'rprh.link';
|
||||||
|
mocks.findUser.mockReset().mockResolvedValue(localUser);
|
||||||
|
mocks.fetchSwarmUserProfile.mockReset();
|
||||||
|
mocks.isSwarmNode.mockReset();
|
||||||
|
mocks.discoverNode.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousDomain === undefined) {
|
||||||
|
delete process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||||
|
} else {
|
||||||
|
process.env.NEXT_PUBLIC_NODE_DOMAIN = previousDomain;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a same-node qualified handle as the local user', async () => {
|
||||||
|
const response = await GET(
|
||||||
|
new Request('https://rprh.link/api/users/wpb8erboy%40rprh.link'),
|
||||||
|
{ params: Promise.resolve({ handle: 'wpb8erboy@rprh.link' }) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
handle: 'wpb8erboy',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mocks.findUser).toHaveBeenCalledWith({ where: { handle: 'wpb8erboy' } });
|
||||||
|
expect(mocks.isSwarmNode).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.discoverNode).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.fetchSwarmUserProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,14 +4,16 @@ import { getSession } from '@/lib/auth';
|
|||||||
import { db, users, follows } from '@/db';
|
import { db, users, follows } from '@/db';
|
||||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
|
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
export async function GET(request: Request, context: RouteContext) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const resolvedHandle = resolveUserHandle(handle);
|
||||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||||
|
const remote = resolvedHandle.remote;
|
||||||
|
|
||||||
// Return mock user if no database
|
// Return mock user if no database
|
||||||
if (!db) {
|
if (!db) {
|
||||||
@@ -36,26 +38,26 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// If user exists but is a remote placeholder (handle contains @), fetch fresh data from remote
|
// If user exists but is a remote placeholder (handle contains @), fetch fresh data from remote
|
||||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
const isRemotePlaceholder = Boolean(user && remote);
|
||||||
|
|
||||||
if (!user || isRemotePlaceholder) {
|
if (!user || isRemotePlaceholder) {
|
||||||
if (remoteHandle && remoteDomain) {
|
if (remote) {
|
||||||
// Only fetch from swarm nodes
|
// Only fetch from swarm nodes
|
||||||
let isSwarm = await isSwarmNode(remoteDomain);
|
let isSwarm = await isSwarmNode(remote.domain);
|
||||||
if (!isSwarm) {
|
if (!isSwarm) {
|
||||||
const discovery = await discoverNode(remoteDomain);
|
const discovery = await discoverNode(remote.domain);
|
||||||
isSwarm = discovery.success;
|
isSwarm = discovery.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSwarm) {
|
if (isSwarm) {
|
||||||
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
|
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, 0);
|
||||||
if (profileData?.profile) {
|
if (profileData?.profile) {
|
||||||
const profile = profileData.profile;
|
const profile = profileData.profile;
|
||||||
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
|
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
|
||||||
const normalizedBotOwnerHandle = rawBotOwnerHandle
|
const normalizedBotOwnerHandle = rawBotOwnerHandle
|
||||||
? rawBotOwnerHandle.includes('@')
|
? rawBotOwnerHandle.includes('@')
|
||||||
? rawBotOwnerHandle
|
? rawBotOwnerHandle
|
||||||
: `${rawBotOwnerHandle}@${remoteDomain}`
|
: `${rawBotOwnerHandle}@${remote.domain}`
|
||||||
: null;
|
: null;
|
||||||
const botOwnerLocalHandle = rawBotOwnerHandle
|
const botOwnerLocalHandle = rawBotOwnerHandle
|
||||||
? rawBotOwnerHandle.split('@')[0]
|
? rawBotOwnerHandle.split('@')[0]
|
||||||
@@ -64,7 +66,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
// CACHE: Upsert the remote user into our local database
|
// CACHE: Upsert the remote user into our local database
|
||||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||||
await upsertRemoteUser({
|
await upsertRemoteUser({
|
||||||
handle: `${profile.handle}@${remoteDomain}`,
|
handle: `${profile.handle}@${remote.domain}`,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
avatarUrl: profile.avatarUrl || null,
|
avatarUrl: profile.avatarUrl || null,
|
||||||
did: profile.did || '',
|
did: profile.did || '',
|
||||||
@@ -74,8 +76,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: `swarm:${remoteDomain}:${profile.handle}`,
|
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||||
handle: `${profile.handle}@${remoteDomain}`,
|
handle: `${profile.handle}@${remote.domain}`,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
bio: profile.bio || null,
|
bio: profile.bio || null,
|
||||||
avatarUrl: profile.avatarUrl || null,
|
avatarUrl: profile.avatarUrl || null,
|
||||||
@@ -87,10 +89,10 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
createdAt: profile.createdAt,
|
createdAt: profile.createdAt,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: remoteDomain,
|
nodeDomain: remote.domain,
|
||||||
isBot: profile.isBot || false,
|
isBot: profile.isBot || false,
|
||||||
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
|
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
|
||||||
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
|
id: `swarm:${remote.domain}:${botOwnerLocalHandle}`,
|
||||||
handle: normalizedBotOwnerHandle,
|
handle: normalizedBotOwnerHandle,
|
||||||
displayName: botOwnerLocalHandle,
|
displayName: botOwnerLocalHandle,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
@@ -111,6 +113,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
export const parseRemoteHandle = (handle: string) => {
|
import { resolveUserHandle } from './user-handle';
|
||||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
|
||||||
const parts = clean.split('@').filter(Boolean);
|
export const parseRemoteHandle = (handle: string) => resolveUserHandle(handle).remote;
|
||||||
if (parts.length === 2) {
|
|
||||||
return { handle: parts[0], domain: parts[1] };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRemoteBaseUrl = (domain: string) =>
|
export const getRemoteBaseUrl = (domain: string) =>
|
||||||
domain.startsWith('http')
|
domain.startsWith('http')
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveUserHandle } from './user-handle';
|
||||||
|
|
||||||
|
describe('resolveUserHandle', () => {
|
||||||
|
it('normalizes an unqualified local handle', () => {
|
||||||
|
expect(resolveUserHandle(' @Alice ', 'social.example.org')).toEqual({
|
||||||
|
canonicalHandle: 'alice',
|
||||||
|
handle: 'alice',
|
||||||
|
domain: null,
|
||||||
|
isQualified: false,
|
||||||
|
isLocal: true,
|
||||||
|
remote: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes a same-node qualified handle to its local database handle', () => {
|
||||||
|
expect(resolveUserHandle('Alice@Social.Example.org', 'https://social.example.org/')).toEqual({
|
||||||
|
canonicalHandle: 'alice',
|
||||||
|
handle: 'alice',
|
||||||
|
domain: 'social.example.org',
|
||||||
|
isQualified: true,
|
||||||
|
isLocal: true,
|
||||||
|
remote: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a genuinely remote qualified handle', () => {
|
||||||
|
expect(resolveUserHandle('Alice@remote.example.org', 'social.example.org')).toEqual({
|
||||||
|
canonicalHandle: 'alice@remote.example.org',
|
||||||
|
handle: 'alice',
|
||||||
|
domain: 'remote.example.org',
|
||||||
|
isQualified: true,
|
||||||
|
isLocal: false,
|
||||||
|
remote: {
|
||||||
|
handle: 'alice',
|
||||||
|
domain: 'remote.example.org',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares development domains including ports', () => {
|
||||||
|
expect(resolveUserHandle('Alice@localhost:43821', 'http://localhost:43821')).toMatchObject({
|
||||||
|
canonicalHandle: 'alice',
|
||||||
|
isLocal: true,
|
||||||
|
remote: null,
|
||||||
|
});
|
||||||
|
expect(resolveUserHandle('Alice@localhost:43822', 'localhost:43821')).toMatchObject({
|
||||||
|
canonicalHandle: 'alice@localhost:43822',
|
||||||
|
isLocal: false,
|
||||||
|
remote: { handle: 'alice', domain: 'localhost:43822' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not misclassify malformed handles as remote', () => {
|
||||||
|
expect(resolveUserHandle('alice@@social.example.org', 'social.example.org')).toMatchObject({
|
||||||
|
canonicalHandle: 'alice@@social.example.org',
|
||||||
|
isQualified: false,
|
||||||
|
isLocal: true,
|
||||||
|
remote: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { getPublicSwarmDomain, normalizeNodeDomain } from './node-domain';
|
||||||
|
|
||||||
|
export interface RemoteUserHandle {
|
||||||
|
handle: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedUserHandle {
|
||||||
|
canonicalHandle: string;
|
||||||
|
handle: string;
|
||||||
|
domain: string | null;
|
||||||
|
isQualified: boolean;
|
||||||
|
isLocal: boolean;
|
||||||
|
remote: RemoteUserHandle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeDomain(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const publicDomain = getPublicSwarmDomain(value);
|
||||||
|
if (publicDomain) return publicDomain;
|
||||||
|
|
||||||
|
const normalized = normalizeNodeDomain(value).replace(/\.$/, '');
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a profile handle relative to the current node.
|
||||||
|
*
|
||||||
|
* Fully-qualified handles remain remote unless their domain identifies this
|
||||||
|
* node. A same-node handle such as `alice@social.example` is canonicalized to
|
||||||
|
* the local database handle `alice` so every profile sub-route behaves the
|
||||||
|
* same way.
|
||||||
|
*/
|
||||||
|
export function resolveUserHandle(
|
||||||
|
value: string,
|
||||||
|
currentDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||||
|
): ResolvedUserHandle {
|
||||||
|
const clean = value.trim().toLowerCase().replace(/^@/, '');
|
||||||
|
const parts = clean.split('@');
|
||||||
|
|
||||||
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||||
|
return {
|
||||||
|
canonicalHandle: clean,
|
||||||
|
handle: clean,
|
||||||
|
domain: null,
|
||||||
|
isQualified: false,
|
||||||
|
isLocal: true,
|
||||||
|
remote: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = parts[0];
|
||||||
|
const domain = canonicalizeDomain(parts[1]);
|
||||||
|
if (!domain) {
|
||||||
|
return {
|
||||||
|
canonicalHandle: clean,
|
||||||
|
handle: clean,
|
||||||
|
domain: null,
|
||||||
|
isQualified: false,
|
||||||
|
isLocal: true,
|
||||||
|
remote: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDomain = canonicalizeDomain(currentDomain);
|
||||||
|
const isLocal = localDomain !== null && domain === localDomain;
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalHandle: isLocal ? handle : `${handle}@${domain}`,
|
||||||
|
handle,
|
||||||
|
domain,
|
||||||
|
isQualified: true,
|
||||||
|
isLocal,
|
||||||
|
remote: isLocal ? null : { handle, domain },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user