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 { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
@@ -11,9 +12,10 @@ export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const targetHandle = resolveUserHandle(handle).canonicalHandle;
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: { handle: handle },
|
||||
where: { handle: targetHandle },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
@@ -39,9 +41,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const targetHandle = resolveUserHandle(handle).canonicalHandle;
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: { handle: handle },
|
||||
where: { handle: targetHandle },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
@@ -96,9 +99,10 @@ export async function DELETE(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const targetHandle = resolveUserHandle(handle).canonicalHandle;
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: { handle: handle },
|
||||
where: { handle: targetHandle },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
|
||||
@@ -7,25 +7,18 @@ import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
|
||||
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
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const resolvedHandle = resolveUserHandle(handle);
|
||||
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||
const remote = resolvedHandle.remote;
|
||||
|
||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||
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.
|
||||
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const resolvedHandle = resolveUserHandle(handle);
|
||||
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||
const remote = resolvedHandle.remote;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||
|
||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||
@@ -254,8 +248,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
const currentUser = await requireSignedAction(signedAction);
|
||||
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const resolvedHandle = resolveUserHandle(handle);
|
||||
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||
const remote = resolvedHandle.remote;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||
|
||||
if (remote) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { db, follows, users, remoteFollowers } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
|
||||
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) {
|
||||
try {
|
||||
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 limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// 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
|
||||
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit);
|
||||
const swarmData = await fetchSwarmFollowers(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.followers) {
|
||||
// Transform to include full handles for local users on that node
|
||||
const followers = swarmData.followers.map((f: any) => ({
|
||||
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||
id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||
handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||
displayName: f.displayName,
|
||||
avatarUrl: f.avatarUrl,
|
||||
bio: f.bio,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { db, follows, users, remoteFollows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
|
||||
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) {
|
||||
try {
|
||||
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 limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// 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
|
||||
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
|
||||
const swarmData = await fetchSwarmFollowing(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.following) {
|
||||
// Transform to include full handles for local users on that node
|
||||
const following = swarmData.following.map((f: any) => ({
|
||||
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`,
|
||||
id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||
handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
|
||||
displayName: f.displayName,
|
||||
avatarUrl: f.avatarUrl,
|
||||
bio: f.bio,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { db, likes, posts, users, userSwarmLikes } from '@/db';
|
||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
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 { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
@@ -44,10 +45,11 @@ const parseMediaJson = (mediaJson: string | null) => {
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
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 limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const remote = resolvedHandle.remote;
|
||||
|
||||
const fetchRemoteLikesRoute = async () => {
|
||||
if (!remote) {
|
||||
@@ -110,7 +112,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: { handle: cleanHandle },
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
const isRemotePlaceholder = Boolean(user && remote);
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
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 { discoverNode } from '@/lib/swarm/discovery';
|
||||
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';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
@@ -213,12 +214,13 @@ async function populateViewerLikeState(
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
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 limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const cursor = searchParams.get('cursor');
|
||||
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const remote = resolvedHandle.remote;
|
||||
const fetchRemotePostsRoute = async () => {
|
||||
if (!remote) {
|
||||
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({
|
||||
where: { handle: cleanHandle },
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
const isRemotePlaceholder = Boolean(user && remote);
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { NextResponse } from 'next/server';
|
||||
import { db, likes, posts, users } from '@/db';
|
||||
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
|
||||
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 { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
@@ -31,11 +32,12 @@ type RouteContext = { params: Promise<{ handle: string }> };
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
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 limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const cursor = searchParams.get('cursor');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const remote = resolvedHandle.remote;
|
||||
|
||||
const fetchRemoteReplies = async () => {
|
||||
if (!remote) {
|
||||
@@ -97,7 +99,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: { handle: cleanHandle },
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
const isRemotePlaceholder = Boolean(user && remote);
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
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 { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||
const resolvedHandle = resolveUserHandle(handle);
|
||||
const cleanHandle = resolvedHandle.canonicalHandle;
|
||||
const remote = resolvedHandle.remote;
|
||||
|
||||
// Return mock user if no database
|
||||
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
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
const isRemotePlaceholder = Boolean(user && remote);
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (remoteHandle && remoteDomain) {
|
||||
if (remote) {
|
||||
// Only fetch from swarm nodes
|
||||
let isSwarm = await isSwarmNode(remoteDomain);
|
||||
let isSwarm = await isSwarmNode(remote.domain);
|
||||
if (!isSwarm) {
|
||||
const discovery = await discoverNode(remoteDomain);
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
isSwarm = discovery.success;
|
||||
}
|
||||
|
||||
if (isSwarm) {
|
||||
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
|
||||
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, 0);
|
||||
if (profileData?.profile) {
|
||||
const profile = profileData.profile;
|
||||
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
|
||||
const normalizedBotOwnerHandle = rawBotOwnerHandle
|
||||
? rawBotOwnerHandle.includes('@')
|
||||
? rawBotOwnerHandle
|
||||
: `${rawBotOwnerHandle}@${remoteDomain}`
|
||||
: `${rawBotOwnerHandle}@${remote.domain}`
|
||||
: null;
|
||||
const botOwnerLocalHandle = rawBotOwnerHandle
|
||||
? rawBotOwnerHandle.split('@')[0]
|
||||
@@ -64,7 +66,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
// CACHE: Upsert the remote user into our local database
|
||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||
await upsertRemoteUser({
|
||||
handle: `${profile.handle}@${remoteDomain}`,
|
||||
handle: `${profile.handle}@${remote.domain}`,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
did: profile.did || '',
|
||||
@@ -74,8 +76,8 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: `swarm:${remoteDomain}:${profile.handle}`,
|
||||
handle: `${profile.handle}@${remoteDomain}`,
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: `${profile.handle}@${remote.domain}`,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
@@ -87,10 +89,10 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
createdAt: profile.createdAt,
|
||||
isRemote: true,
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
nodeDomain: remote.domain,
|
||||
isBot: profile.isBot || false,
|
||||
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
|
||||
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
|
||||
id: `swarm:${remote.domain}:${botOwnerLocalHandle}`,
|
||||
handle: normalizedBotOwnerHandle,
|
||||
displayName: botOwnerLocalHandle,
|
||||
avatarUrl: null,
|
||||
@@ -111,6 +113,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
export 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;
|
||||
};
|
||||
import { resolveUserHandle } from './user-handle';
|
||||
|
||||
export const parseRemoteHandle = (handle: string) => resolveUserHandle(handle).remote;
|
||||
|
||||
export const getRemoteBaseUrl = (domain: string) =>
|
||||
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