Remote follow fix 2
This commit is contained in:
@@ -13,6 +13,23 @@ type SearchUser = {
|
|||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decodeEntities = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\\d+);/g, (_, num) => String.fromCharCode(Number(num)))
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
const sanitizeText = (value?: string | null) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const withoutTags = value.replace(/<[^>]*>/g, ' ');
|
||||||
|
const decoded = decodeEntities(withoutTags);
|
||||||
|
return decoded.replace(/\\s+/g, ' ').trim() || null;
|
||||||
|
};
|
||||||
|
|
||||||
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
||||||
let trimmed = query.trim();
|
let trimmed = query.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -35,7 +52,7 @@ const buildRemoteUser = (
|
|||||||
domain: string,
|
domain: string,
|
||||||
): SearchUser | null => {
|
): SearchUser | null => {
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const displayName = profile.name || profile.preferredUsername || null;
|
const displayName = sanitizeText(profile.name) || sanitizeText(profile.preferredUsername) || null;
|
||||||
const username = profile.preferredUsername || handle;
|
const username = profile.preferredUsername || handle;
|
||||||
if (!username) return null;
|
if (!username) return null;
|
||||||
const fullHandle = `${username}@${domain}`.replace(/^@/, '');
|
const fullHandle = `${username}@${domain}`.replace(/^@/, '');
|
||||||
@@ -47,7 +64,7 @@ const buildRemoteUser = (
|
|||||||
handle: fullHandle,
|
handle: fullHandle,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl: iconUrl ?? null,
|
avatarUrl: iconUrl ?? null,
|
||||||
bio: profile.summary ?? null,
|
bio: sanitizeText(profile.summary),
|
||||||
profileUrl: profileUrl ?? null,
|
profileUrl: profileUrl ?? null,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { db, follows, users, notifications } from '@/db';
|
import { db, follows, users, notifications } from '@/db';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||||
|
import { createFollowActivity } from '@/lib/activitypub/activities';
|
||||||
|
import { deliverActivity } from '@/lib/activitypub/outbox';
|
||||||
|
|
||||||
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 cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
const remote = parseRemoteHandle(handle);
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
return NextResponse.json({ following: false, remote: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
}
|
}
|
||||||
@@ -58,11 +76,36 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
const currentUser = await requireAuth();
|
const currentUser = await requireAuth();
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
const remote = parseRemoteHandle(handle);
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||||
|
if (!remoteProfile) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
|
||||||
|
if (!targetInbox) {
|
||||||
|
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const activityId = crypto.randomUUID();
|
||||||
|
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
|
||||||
|
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
||||||
|
const privateKey = currentUser.privateKeyEncrypted;
|
||||||
|
if (!privateKey) {
|
||||||
|
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
|
||||||
|
}
|
||||||
|
const result = await deliverActivity(followActivity, targetInbox, privateKey, keyId);
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, following: true, remote: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
}
|
}
|
||||||
@@ -137,6 +180,11 @@ export async function DELETE(request: Request, context: RouteContext) {
|
|||||||
const currentUser = await requireAuth();
|
const currentUser = await requireAuth();
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
const remote = parseRemoteHandle(handle);
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
return NextResponse.json({ error: 'Unfollow for remote users not supported yet' }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
|
|||||||
@@ -2,13 +2,32 @@ import { NextResponse } from 'next/server';
|
|||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { userToActor } from '@/lib/activitypub/actor';
|
import { userToActor } from '@/lib/activitypub/actor';
|
||||||
|
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
|
const decodeEntities = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)))
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
const sanitizeText = (value?: string | null) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const withoutTags = value.replace(/<[^>]*>/g, ' ');
|
||||||
|
const decoded = decodeEntities(withoutTags);
|
||||||
|
return decoded.replace(/\s+/g, ' ').trim() || 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 cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
|
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||||
|
|
||||||
// Return mock user if no database
|
// Return mock user if no database
|
||||||
if (!db) {
|
if (!db) {
|
||||||
@@ -33,6 +52,32 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
if (remoteHandle && remoteDomain) {
|
||||||
|
const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain);
|
||||||
|
if (remoteProfile) {
|
||||||
|
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
|
||||||
|
const iconUrl = typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url;
|
||||||
|
const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url;
|
||||||
|
const profileUrl = typeof remoteProfile.url === 'string' ? remoteProfile.url : remoteProfile.id;
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`,
|
||||||
|
handle: `${remoteProfile.preferredUsername || remoteHandle}@${remoteDomain}`,
|
||||||
|
displayName,
|
||||||
|
bio: sanitizeText(remoteProfile.summary),
|
||||||
|
avatarUrl: iconUrl ?? null,
|
||||||
|
headerUrl: headerUrl ?? null,
|
||||||
|
followersCount: 0,
|
||||||
|
followingCount: 0,
|
||||||
|
postsCount: 0,
|
||||||
|
website: profileUrl ?? null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isRemote: true,
|
||||||
|
profileUrl: profileUrl ?? null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, type ElementType } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
|
import { SearchIcon, TrendingIcon, UsersIcon, HeartIcon, RepeatIcon, MessageIcon } from '@/components/Icons';
|
||||||
|
|
||||||
@@ -108,14 +108,8 @@ function PostCard({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
const isRemote = Boolean(user.isRemote && user.profileUrl);
|
|
||||||
const Wrapper: ElementType = isRemote ? 'a' : Link;
|
|
||||||
const wrapperProps = isRemote
|
|
||||||
? { href: user.profileUrl || '#', target: '_blank', rel: 'noopener noreferrer' }
|
|
||||||
: { href: `/${user.handle}` };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper {...wrapperProps} className="user-card">
|
<Link href={`/${user.handle}`} className="user-card">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={user.avatarUrl} alt={user.displayName} />
|
<img src={user.avatarUrl} alt={user.displayName} />
|
||||||
@@ -128,7 +122,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
<div className="user-card-handle">@{user.handle}</div>
|
<div className="user-card-handle">@{user.handle}</div>
|
||||||
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, type ElementType } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@@ -75,15 +75,9 @@ const FlagIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
const isRemote = Boolean(user.isRemote && user.profileUrl);
|
|
||||||
const Wrapper: ElementType = isRemote ? 'a' : Link;
|
|
||||||
const wrapperProps = isRemote
|
|
||||||
? { href: user.profileUrl || '#', target: '_blank', rel: 'noopener noreferrer' }
|
|
||||||
: { href: `/@${user.handle}` };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<Link
|
||||||
{...wrapperProps}
|
href={`/@${user.handle}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -117,7 +111,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface ActivityPubProfile {
|
|||||||
name?: string;
|
name?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
inbox?: string;
|
||||||
|
endpoints?: {
|
||||||
|
sharedInbox?: string;
|
||||||
|
};
|
||||||
icon?: {
|
icon?: {
|
||||||
url: string;
|
url: string;
|
||||||
} | string;
|
} | string;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface User {
|
|||||||
website?: string | null;
|
website?: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
movedTo?: string | null;
|
movedTo?: string | null;
|
||||||
|
isRemote?: boolean;
|
||||||
|
profileUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user