Files
Synapsis/src/app/api/search/route.ts
T
AskIt d36c1c93e5 feat: Add user likes tab and local timeline, update branding assets
- Add likes tab to user profiles displaying user's liked posts
- Implement new `/api/users/[handle]/likes` endpoint to fetch user's liked posts
- Add local timeline feed type to show only local node posts without fediverse content
- Update favicon and add new logotext branding asset
- Enhance search results with isLiked and isReposted status for authenticated users
- Populate like and repost information in search posts endpoint
- Update profile page tabs to conditionally show likes tab for non-bot users
- Add loading states and empty state messaging for likes tab
- Remove deprecated guide page
- Improve bot settings page and content generation logic
2026-01-25 19:45:34 +01:00

207 lines
7.7 KiB
TypeScript

import { NextResponse } from 'next/server';
import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type SearchUser = {
id: string;
handle: string;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
profileUrl?: string | null;
isRemote?: boolean;
isBot?: 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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 => {
let trimmed = query.trim();
if (!trimmed) return null;
if (trimmed.startsWith('acct:')) {
trimmed = trimmed.slice(5);
}
const withoutPrefix = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
if (withoutPrefix.includes(' ')) return null;
const parts = withoutPrefix.split('@').filter(Boolean);
if (parts.length !== 2) return null;
const [handle, domain] = parts;
if (!handle || !domain) return null;
if (!domain.includes('.') && !domain.includes(':')) return null;
return { handle: handle.toLowerCase(), domain: domain.toLowerCase() };
};
const buildRemoteUser = (
profile: Awaited<ReturnType<typeof resolveRemoteUser>>,
handle: string,
domain: string,
): SearchUser | null => {
if (!profile) return null;
const displayName = sanitizeText(profile.name) || sanitizeText(profile.preferredUsername) || null;
const username = profile.preferredUsername || handle;
if (!username) return null;
const fullHandle = `${username}@${domain}`.replace(/^@/, '');
const iconUrl = typeof profile.icon === 'string' ? profile.icon : profile.icon?.url;
const profileUrl = typeof profile.url === 'string' ? profile.url : profile.id;
return {
id: profile.id || profileUrl || `remote:${fullHandle}`,
handle: fullHandle,
displayName,
avatarUrl: iconUrl ?? null,
bio: sanitizeText(profile.summary),
profileUrl: profileUrl ?? null,
isRemote: true,
};
};
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
const type = searchParams.get('type') || 'all'; // all, users, posts
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
if (!query.trim()) {
return NextResponse.json({ users: [], posts: [] });
}
// Return empty if no database
if (!db) {
return NextResponse.json({
users: [],
posts: [],
message: 'Search requires database connection'
});
}
const searchPattern = `%${query}%`;
let searchUsers: SearchUser[] = [];
let searchPosts: typeof posts.$inferSelect[] = [];
// Search users
if (type === 'all' || type === 'users') {
const userConditions = and(
or(
ilike(users.handle, searchPattern),
ilike(users.displayName, searchPattern),
ilike(users.bio, searchPattern)
),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
);
searchUsers = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
isBot: users.isBot,
})
.from(users)
.where(userConditions)
.limit(limit);
}
// Federated user lookup (exact handle@domain queries)
if ((type === 'all' || type === 'users') && searchUsers.length < limit) {
const parsedRemote = parseRemoteHandleQuery(query);
if (parsedRemote) {
const remoteProfile = await resolveRemoteUser(parsedRemote.handle, parsedRemote.domain);
const remoteUser = buildRemoteUser(remoteProfile, parsedRemote.handle, parsedRemote.domain);
if (remoteUser && !searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
}
}
}
const moderatedUsers = await db.select({ id: users.id })
.from(users)
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
const moderatedIds = moderatedUsers.map((item) => item.id);
// Search posts
if (type === 'all' || type === 'posts') {
const postConditions = [
ilike(posts.content, searchPattern),
eq(posts.isRemoved, false),
];
if (moderatedIds.length) {
postConditions.push(notInArray(posts.userId, moderatedIds));
}
const postResults = await db.query.posts.findMany({
where: and(...postConditions),
with: {
author: true,
media: true,
bot: true,
},
orderBy: [desc(posts.createdAt)],
limit,
});
searchPosts = postResults;
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && searchPosts.length > 0) {
const viewer = session.user;
const postIds = searchPosts.map(p => p.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
searchPosts = searchPosts.map(p => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
})) as any;
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
}
return NextResponse.json({
users: searchUsers,
posts: searchPosts,
});
} catch (error) {
console.error('Search error:', error);
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
}
}