Initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
|
||||
const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
|
||||
|
||||
return NextResponse.json({
|
||||
links: [
|
||||
{
|
||||
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||
href: `https://${nodeDomain}/nodeinfo/2.1`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, handleRegistry } from '@/db';
|
||||
import { desc, eq, gt } from 'drizzle-orm';
|
||||
import { normalizeHandle } from '@/lib/federation/handles';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ handles: [] });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handleParam = searchParams.get('handle');
|
||||
const sinceParam = searchParams.get('since');
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
|
||||
|
||||
if (handleParam) {
|
||||
const cleanHandle = normalizeHandle(handleParam);
|
||||
const entry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
return NextResponse.json({ handles: [] });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
handles: [{
|
||||
handle: entry.handle,
|
||||
did: entry.did,
|
||||
nodeDomain: entry.nodeDomain,
|
||||
updatedAt: entry.updatedAt,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
const sinceDate = sinceParam ? new Date(sinceParam) : null;
|
||||
const entries = await db.query.handleRegistry.findMany({
|
||||
where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
|
||||
orderBy: [desc(handleRegistry.updatedAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
handles: entries.map((entry) => ({
|
||||
handle: entry.handle,
|
||||
did: entry.did,
|
||||
nodeDomain: entry.nodeDomain,
|
||||
updatedAt: entry.updatedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Handle export error:', error);
|
||||
return NextResponse.json({ error: 'Failed to export handles' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Well-Known Synapsis Swarm Endpoint
|
||||
*
|
||||
* GET /.well-known/synapsis-swarm
|
||||
*
|
||||
* Returns information about this node and the swarm it knows about.
|
||||
* This is a standardized discovery endpoint that other nodes can use
|
||||
* to find and join the swarm.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||
import { getActiveSwarmNodes, getSwarmStats, getSeedNodes } from '@/lib/swarm/registry';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeNodes = searchParams.get('nodes') !== 'false';
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||
|
||||
const announcement = await buildAnnouncement();
|
||||
const stats = await getSwarmStats();
|
||||
const seeds = await getSeedNodes();
|
||||
|
||||
const response: {
|
||||
// This node's info
|
||||
node: {
|
||||
domain: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
publicKey: string;
|
||||
softwareVersion: string;
|
||||
capabilities: string[];
|
||||
};
|
||||
// Swarm metadata
|
||||
swarm: {
|
||||
totalNodes: number;
|
||||
activeNodes: number;
|
||||
totalUsers: number;
|
||||
totalPosts: number;
|
||||
seeds: string[];
|
||||
};
|
||||
// Known nodes (optional)
|
||||
nodes?: Awaited<ReturnType<typeof getActiveSwarmNodes>>;
|
||||
} = {
|
||||
node: {
|
||||
domain: announcement.domain,
|
||||
name: announcement.name,
|
||||
description: announcement.description,
|
||||
publicKey: announcement.publicKey,
|
||||
softwareVersion: announcement.softwareVersion,
|
||||
capabilities: announcement.capabilities,
|
||||
},
|
||||
swarm: {
|
||||
...stats,
|
||||
seeds,
|
||||
},
|
||||
};
|
||||
|
||||
if (includeNodes) {
|
||||
response.nodes = await getActiveSwarmNodes(limit);
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Synapsis swarm well-known error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch swarm info' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateWebFingerResponse, parseWebFingerResource } from '@/lib/activitypub/webfinger';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const resource = searchParams.get('resource');
|
||||
|
||||
if (!resource) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing resource parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseWebFingerResource(resource);
|
||||
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid resource format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// Check if this is our domain
|
||||
if (parsed.domain !== nodeDomain && parsed.domain !== nodeDomain.replace(/:\d+$/, '')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resource not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, parsed.handle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = generateWebFingerResponse(user.handle, nodeDomain);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Content-Type': 'application/jrd+json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { User, Post } from '@/lib/types';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Rocket, MoreHorizontal } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
interface BotOwner {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
// Strip HTML tags from a string
|
||||
const stripHtml = (html: string | null | undefined): string | null => {
|
||||
if (!html) return null;
|
||||
return html.replace(/<[^>]*>/g, '').trim() || null;
|
||||
};
|
||||
|
||||
function UserRow({ user }: { user: UserSummary }) {
|
||||
return (
|
||||
<Link href={`/${user.handle}`} className="user-row">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="user-row-content">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{user.displayName || user.handle}</span>
|
||||
{user.isBot && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Bot size={12} />
|
||||
AI Account
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div>
|
||||
{user.bio && stripHtml(user.bio) && (
|
||||
<div className="user-row-bio">{stripHtml(user.bio)}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'posts' | 'likes' | 'followers' | 'following'>('posts');
|
||||
const [followers, setFollowers] = useState<UserSummary[]>([]);
|
||||
const [following, setFollowing] = useState<UserSummary[]>([]);
|
||||
const [postsLoading, setPostsLoading] = useState(true);
|
||||
const [likesLoading, setLikesLoading] = useState(false);
|
||||
const [followersLoading, setFollowersLoading] = useState(false);
|
||||
const [followingLoading, setFollowingLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
displayName: '',
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
headerUrl: '',
|
||||
website: '',
|
||||
});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setSaveError(null);
|
||||
setFollowers([]);
|
||||
setFollowing([]);
|
||||
setLikedPosts([]);
|
||||
// Get current user
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
.then(data => setCurrentUser(data.user))
|
||||
.catch(() => { });
|
||||
|
||||
// Get profile
|
||||
fetch(`/api/users/${handle}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setUser(data.user);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
setPostsLoading(true);
|
||||
fetch(`/api/users/${handle}/posts`)
|
||||
.then(res => res.json())
|
||||
.then(data => setPosts(data.posts || []))
|
||||
.catch(() => { })
|
||||
.finally(() => setPostsLoading(false));
|
||||
}, [handle]);
|
||||
|
||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||
const method = currentLiked ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/like`, { method });
|
||||
};
|
||||
|
||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||
const method = currentReposted ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/repost`, { method });
|
||||
};
|
||||
|
||||
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}`);
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
if (user && isOwnProfile) {
|
||||
setUser({
|
||||
...user,
|
||||
postsCount: (user.postsCount || 0) - 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && currentUser?.handle === user.handle) {
|
||||
setProfileForm({
|
||||
displayName: user.displayName || '',
|
||||
bio: user.bio || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
headerUrl: user.headerUrl || '',
|
||||
website: user.website || '',
|
||||
});
|
||||
}
|
||||
}, [user, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || !user || currentUser.handle === user.handle) {
|
||||
setIsFollowing(false);
|
||||
setIsBlocked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/users/${handle}/follow`)
|
||||
.then(res => res.json())
|
||||
.then(data => setIsFollowing(!!data.following))
|
||||
.catch(() => setIsFollowing(false));
|
||||
|
||||
fetch(`/api/users/${handle}/block`)
|
||||
.then(res => res.json())
|
||||
.then(data => setIsBlocked(!!data.blocked))
|
||||
.catch(() => setIsBlocked(false));
|
||||
}, [currentUser, user, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'followers') {
|
||||
setFollowersLoading(true);
|
||||
fetch(`/api/users/${handle}/followers`)
|
||||
.then(res => res.json())
|
||||
.then(data => setFollowers(data.followers || []))
|
||||
.catch(() => setFollowers([]))
|
||||
.finally(() => setFollowersLoading(false));
|
||||
}
|
||||
|
||||
if (activeTab === 'following') {
|
||||
setFollowingLoading(true);
|
||||
fetch(`/api/users/${handle}/following`)
|
||||
.then(res => res.json())
|
||||
.then(data => setFollowing(data.following || []))
|
||||
.catch(() => setFollowing([]))
|
||||
.finally(() => setFollowingLoading(false));
|
||||
}
|
||||
|
||||
if (activeTab === 'likes') {
|
||||
setLikesLoading(true);
|
||||
fetch(`/api/users/${handle}/likes`)
|
||||
.then(res => res.json())
|
||||
.then(data => setLikedPosts(data.posts || []))
|
||||
.catch(() => setLikedPosts([]))
|
||||
.finally(() => setLikesLoading(false));
|
||||
}
|
||||
}, [activeTab, handle]);
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const method = isFollowing ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
||||
|
||||
if (res.ok && user) {
|
||||
setIsFollowing(!isFollowing);
|
||||
setUser({
|
||||
...user,
|
||||
followersCount: isFollowing ? (user.followersCount || 0) - 1 : (user.followersCount || 0) + 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlock = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const method = isBlocked ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${handle}/block`, { method });
|
||||
|
||||
if (res.ok) {
|
||||
setIsBlocked(!isBlocked);
|
||||
if (!isBlocked) {
|
||||
// If blocking, also unfollow
|
||||
setIsFollowing(false);
|
||||
}
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!isOwnProfile) return;
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profileForm),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Profile update failed', error);
|
||||
setSaveError('Unable to update profile. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 600 }}>User not found</h1>
|
||||
<Link href="/" className="btn btn-primary">Go home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwnProfile = currentUser?.handle === user.handle;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--background)',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Account Moved Banner */}
|
||||
{user.movedTo && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<Rocket size={24} style={{ color: 'var(--warning)' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--warning)', marginBottom: '4px' }}>
|
||||
This account has moved
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
|
||||
This user has migrated to a new node:{' '}
|
||||
<a
|
||||
href={user.movedTo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{user.movedTo.replace('https://', '').replace('/api/users/', '/@').replace('/users/', '/@')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Header */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{/* Banner */}
|
||||
<div style={{
|
||||
height: '150px',
|
||||
background: user.headerUrl
|
||||
? `url(${user.headerUrl}) center/cover`
|
||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||
}} />
|
||||
|
||||
{/* Avatar & Actions */}
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<div className="avatar avatar-lg" style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
fontSize: '36px',
|
||||
border: '4px solid var(--background)',
|
||||
marginTop: '-48px',
|
||||
}}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{!isOwnProfile && currentUser && (
|
||||
<>
|
||||
{!isBlocked && (
|
||||
<button
|
||||
className={`btn ${isFollowing ? '' : 'btn-primary'}`}
|
||||
onClick={handleFollow}
|
||||
>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
style={{ padding: '8px' }}
|
||||
>
|
||||
<MoreHorizontal size={20} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99,
|
||||
}}
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '100%',
|
||||
marginTop: '4px',
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
minWidth: '160px',
|
||||
zIndex: 100,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleBlock}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: isBlocked ? 'var(--foreground)' : 'var(--error)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{isBlocked ? 'Unblock user' : 'Block user'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOwnProfile && (
|
||||
<button className="btn" onClick={() => setIsEditing(!isEditing)}>
|
||||
{isEditing ? 'Close' : 'Edit Profile'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2>
|
||||
<p style={{ color: 'var(--foreground-tertiary)' }}>{formatFullHandle(user.handle)}</p>
|
||||
|
||||
{user.bio && (
|
||||
<p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<CalendarIcon />
|
||||
<span>Joined {formatDate(user.createdAt || new Date().toISOString())}</span>
|
||||
</div>
|
||||
|
||||
{user.website && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '4px',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<Link
|
||||
href={user.website.startsWith('http') ? user.website : `https://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
{user.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot indicator and owner info */}
|
||||
{user.isBot && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--accent-muted)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<Bot size={16} style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ color: 'var(--foreground-secondary)' }}>
|
||||
Automated account
|
||||
{(user as any).botOwner && (
|
||||
<>
|
||||
{' · Managed by '}
|
||||
<Link
|
||||
href={`/${(user as any).botOwner.handle}`}
|
||||
style={{ color: 'var(--accent)', fontWeight: 500 }}
|
||||
>
|
||||
@{(user as any).botOwner.handle}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', marginTop: '12px' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('following')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong>{user.followingCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('followers')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong>{user.followersCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwnProfile && isEditing && (
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<div className="card" style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Edit profile</div>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Display name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={profileForm.displayName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, displayName: e.target.value })}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Bio</label>
|
||||
<AutoTextarea
|
||||
className="input"
|
||||
value={profileForm.bio}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, bio: e.target.value })}
|
||||
maxLength={160}
|
||||
style={{ minHeight: '80px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Website</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://example.com"
|
||||
value={profileForm.website}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, website: e.target.value })}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setProfileForm(prev => ({ ...prev, avatarUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError('Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{profileForm.avatarUrl && (
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={profileForm.avatarUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setProfileForm(prev => ({ ...prev, headerUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError('Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{profileForm.headerUrl && (
|
||||
<div style={{ width: '80px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={profileForm.headerUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{saveError && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||
{(user?.isBot
|
||||
? ['posts', 'followers', 'following'] as const
|
||||
: ['posts', 'likes', 'followers', 'following'] as const
|
||||
).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
color: activeTab === tab ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'posts' && (
|
||||
postsLoading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No posts yet</p>
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post, index) => (
|
||||
<PostCard
|
||||
key={`${post.id}-${index}`}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'likes' && (
|
||||
likesLoading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : likedPosts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No liked posts yet</p>
|
||||
</div>
|
||||
) : (
|
||||
likedPosts.map((post, index) => (
|
||||
<PostCard
|
||||
key={`${post.id}-${index}`}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'followers' && (
|
||||
followersLoading ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading followers...
|
||||
</div>
|
||||
) : followers.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No followers yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{followers.map(follower => (
|
||||
<UserRow key={follower.id} user={follower} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'following' && (
|
||||
followingLoading ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading following...
|
||||
</div>
|
||||
) : following.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Not following anyone yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{following.map(userItem => (
|
||||
<UserRow key={userItem.id} user={userItem} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Compose } from '@/components/Compose';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { Post } from '@/lib/types';
|
||||
|
||||
export default function PostDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const handle = params.handle as string;
|
||||
const id = params.id as string;
|
||||
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [replies, setReplies] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPostDetail = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${id}`);
|
||||
if (!res.ok) {
|
||||
throw new Error('Post not found');
|
||||
}
|
||||
const data = await res.json();
|
||||
setPost(data.post);
|
||||
setReplies(data.replies || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load post');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPostDetail();
|
||||
}, [id]);
|
||||
|
||||
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
|
||||
// Check if we're replying to a swarm post
|
||||
let swarmReplyTo: { postId: string; nodeDomain: string } | undefined;
|
||||
let localReplyToId: string | undefined = replyToId;
|
||||
|
||||
if (post?.isSwarm && post.nodeDomain && post.originalPostId) {
|
||||
// This is a reply to a swarm post - send to the origin node
|
||||
swarmReplyTo = {
|
||||
postId: post.originalPostId,
|
||||
nodeDomain: post.nodeDomain,
|
||||
};
|
||||
localReplyToId = undefined; // Don't set local replyToId for swarm posts
|
||||
}
|
||||
|
||||
const res = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Add the new reply to the top of the list or re-fetch
|
||||
setReplies([{ ...data.post, author: user }, ...replies]);
|
||||
if (post) {
|
||||
setPost({ ...post, repliesCount: post.repliesCount + 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||
const method = currentLiked ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/like`, { method });
|
||||
};
|
||||
|
||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||
const method = currentReposted ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/repost`, { method });
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
if (postId === id) {
|
||||
router.push(`/${handle}`);
|
||||
} else {
|
||||
setReplies(prev => prev.filter(r => r.id !== postId));
|
||||
if (post) {
|
||||
setPost({ ...post, repliesCount: Math.max(0, post.repliesCount - 1) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div style={{ padding: '48px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '20px', marginBottom: '16px' }}>{error || 'Post not found'}</h1>
|
||||
<button className="btn btn-primary" onClick={() => router.back()}>Go Back</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--background)',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--foreground)', cursor: 'pointer', display: 'flex' }}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Post</h1>
|
||||
</header>
|
||||
|
||||
<PostCard
|
||||
post={post}
|
||||
isDetail
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onDelete={handleDelete}
|
||||
onComment={() => {
|
||||
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
|
||||
composer?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<Compose
|
||||
onPost={handlePost}
|
||||
replyingTo={post}
|
||||
isReply
|
||||
placeholder="Post your reply"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="replies-list" style={{ paddingBottom: '64px' }}>
|
||||
{replies.map((reply) => (
|
||||
<PostCard
|
||||
key={reply.id}
|
||||
post={reply}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onDelete={handleDelete}
|
||||
onComment={(p) => {
|
||||
// In detail view, commenting on a reply should probably just focus the main composer
|
||||
// but we could also implement nested replies later.
|
||||
// For now, let's keep it simple.
|
||||
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
|
||||
composer?.focus();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
isSuspended: boolean;
|
||||
isSilenced: boolean;
|
||||
suspensionReason?: string | null;
|
||||
silenceReason?: string | null;
|
||||
createdAt: string;
|
||||
isBot?: boolean;
|
||||
};
|
||||
|
||||
type AdminPost = {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
isRemoved: boolean;
|
||||
removedReason?: string | null;
|
||||
author: {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type Report = {
|
||||
id: string;
|
||||
targetType: 'post' | 'user';
|
||||
targetId: string;
|
||||
reason: string;
|
||||
status: 'open' | 'resolved';
|
||||
createdAt: string;
|
||||
reporter?: {
|
||||
id: string;
|
||||
handle: string;
|
||||
} | null;
|
||||
target?: AdminPost | AdminUser | null;
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const { showToast } = useToast();
|
||||
const { refreshAccentColor } = useAccentColor();
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||
const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'settings'>('reports');
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [posts, setPosts] = useState<AdminPost[]>([]);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
|
||||
const [nodeSettings, setNodeSettings] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
longDescription: '',
|
||||
rules: '',
|
||||
bannerUrl: '',
|
||||
logoUrl: '',
|
||||
faviconUrl: '',
|
||||
accentColor: '#FFFFFF',
|
||||
isNsfw: false,
|
||||
});
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
|
||||
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/me')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setIsAdmin(!!data.isAdmin))
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
const loadReports = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/reports?status=${reportStatus}`);
|
||||
const data = await res.json();
|
||||
setReports(data.reports || []);
|
||||
} catch {
|
||||
setReports([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPosts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/posts?status=all');
|
||||
const data = await res.json();
|
||||
setPosts(data.posts || []);
|
||||
} catch {
|
||||
setPosts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/users');
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
} catch {
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNodeSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/node');
|
||||
const data = await res.json();
|
||||
setNodeSettings({
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
longDescription: data.longDescription || '',
|
||||
rules: data.rules || '',
|
||||
bannerUrl: data.bannerUrl || '',
|
||||
logoUrl: data.logoUrl || '',
|
||||
faviconUrl: data.faviconUrl || '',
|
||||
accentColor: data.accentColor || '#FFFFFF',
|
||||
isNsfw: data.isNsfw || false,
|
||||
});
|
||||
} catch {
|
||||
// error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
if (tab === 'reports') loadReports();
|
||||
if (tab === 'posts') loadPosts();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'settings') loadNodeSettings();
|
||||
}, [tab, isAdmin, reportStatus]);
|
||||
|
||||
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
|
||||
const note = status === 'resolved' ? window.prompt('Resolution note (optional):') || '' : '';
|
||||
await fetch(`/api/admin/reports/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, note }),
|
||||
});
|
||||
loadReports();
|
||||
};
|
||||
|
||||
const handlePostAction = async (id: string, action: 'remove' | 'restore') => {
|
||||
const reason = action === 'remove' ? window.prompt('Reason (optional):') || '' : '';
|
||||
await fetch(`/api/admin/posts/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, reason }),
|
||||
});
|
||||
if (tab === 'reports') {
|
||||
loadReports();
|
||||
} else {
|
||||
loadPosts();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (override?: typeof nodeSettings) => {
|
||||
const payload = override ?? nodeSettings;
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/node', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Settings saved!', 'success');
|
||||
refreshAccentColor();
|
||||
} else {
|
||||
showToast('Failed to save settings.', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to save settings.', 'error');
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setBannerUploadError(null);
|
||||
setIsUploadingBanner(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.url) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...nodeSettings,
|
||||
bannerUrl: data.media?.url || data.url,
|
||||
};
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
} catch (error) {
|
||||
console.error('Banner upload failed', error);
|
||||
setBannerUploadError('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploadingBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setLogoUploadError(null);
|
||||
setIsUploadingLogo(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.url) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...nodeSettings,
|
||||
logoUrl: data.media?.url || data.url,
|
||||
};
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
} catch (error) {
|
||||
console.error('Logo upload failed', error);
|
||||
setLogoUploadError('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setFaviconUploadError(null);
|
||||
setIsUploadingFavicon(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.url) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...nodeSettings,
|
||||
faviconUrl: data.media?.url || data.url,
|
||||
};
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
} catch (error) {
|
||||
console.error('Favicon upload failed', error);
|
||||
setFaviconUploadError('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploadingFavicon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => {
|
||||
const needsReason = action === 'suspend' || action === 'silence';
|
||||
const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
|
||||
await fetch(`/api/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, reason }),
|
||||
});
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const reportCounts = useMemo(() => {
|
||||
return {
|
||||
open: reports.filter((r) => r.status === 'open').length,
|
||||
resolved: reports.filter((r) => r.status === 'resolved').length,
|
||||
};
|
||||
}, [reports]);
|
||||
|
||||
if (isAdmin === null) {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<div className="admin-card">Checking permissions...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<div className="admin-card">
|
||||
<h1>Moderation</h1>
|
||||
<p>You do not have access to this page.</p>
|
||||
<Link href="/" className="btn btn-primary" style={{ marginTop: '12px' }}>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<div className="admin-header">
|
||||
<div>
|
||||
<h1>Moderation Dashboard</h1>
|
||||
<p>Manage reports, posts, and user actions.</p>
|
||||
</div>
|
||||
<Link href="/" className="btn btn-ghost">
|
||||
Return to feed
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button className={`admin-tab ${tab === 'reports' ? 'active' : ''}`} onClick={() => setTab('reports')}>
|
||||
Reports
|
||||
</button>
|
||||
<button className={`admin-tab ${tab === 'posts' ? 'active' : ''}`} onClick={() => setTab('posts')}>
|
||||
Posts
|
||||
</button>
|
||||
<button className={`admin-tab ${tab === 'users' ? 'active' : ''}`} onClick={() => setTab('users')}>
|
||||
Users
|
||||
</button>
|
||||
<button className={`admin-tab ${tab === 'settings' ? 'active' : ''}`} onClick={() => setTab('settings')}>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'reports' && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-filters">
|
||||
{(['open', 'resolved', 'all'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={`pill ${reportStatus === status ? 'active' : ''}`}
|
||||
onClick={() => setReportStatus(status)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-stats">
|
||||
<span>Open: {reportCounts.open}</span>
|
||||
<span>Resolved: {reportCounts.resolved}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">Loading reports...</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="admin-empty">No reports found.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="admin-row">
|
||||
<div className="admin-row-main">
|
||||
<div className="admin-row-title">
|
||||
<span className={`status-pill ${report.status}`}>
|
||||
{report.status}
|
||||
</span>
|
||||
<span className="admin-row-meta">
|
||||
{report.targetType.toUpperCase()} report
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-row-body">
|
||||
{report.reason}
|
||||
</div>
|
||||
<div className="admin-row-sub">
|
||||
Reported by {report.reporter?.handle || 'anonymous'} • {formatDate(report.createdAt)}
|
||||
</div>
|
||||
{report.targetType === 'post' && report.target && 'content' in report.target && (
|
||||
<div className="admin-row-target">
|
||||
<strong>@{report.target.author.handle}:</strong> {report.target.content || '[repost]'}
|
||||
</div>
|
||||
)}
|
||||
{report.targetType === 'user' && report.target && 'handle' in report.target && (
|
||||
<div className="admin-row-target">
|
||||
User: @{report.target.handle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-row-actions">
|
||||
{report.targetType === 'post' && report.target && 'content' in report.target && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
const target = report.target as AdminPost;
|
||||
handlePostAction(target.id, target.isRemoved ? 'restore' : 'remove');
|
||||
}}
|
||||
>
|
||||
{(report.target as AdminPost).isRemoved ? 'Restore post' : 'Remove post'}
|
||||
</button>
|
||||
)}
|
||||
{report.status === 'open' ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleReportResolve(report.id, 'resolved')}>
|
||||
Resolve
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleReportResolve(report.id, 'open')}>
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'posts' && (
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="admin-empty">Loading posts...</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="admin-empty">No posts found.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="admin-row">
|
||||
<div className="admin-row-main">
|
||||
<div className="admin-row-title">
|
||||
<span className={`status-pill ${post.isRemoved ? 'removed' : 'active'}`}>
|
||||
{post.isRemoved ? 'removed' : 'active'}
|
||||
</span>
|
||||
<span className="admin-row-meta">
|
||||
@{post.author.handle} • {formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-row-body">{post.content || '[repost]'}</div>
|
||||
{post.removedReason && (
|
||||
<div className="admin-row-sub">Reason: {post.removedReason}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-row-actions">
|
||||
{post.isRemoved ? (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handlePostAction(post.id, 'restore')}>
|
||||
Restore
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handlePostAction(post.id, 'remove')}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'users' && (
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="admin-empty">Loading users...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="admin-empty">No users found.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="admin-row">
|
||||
<div className="admin-row-main">
|
||||
<div className="admin-row-title">
|
||||
<span className={`status-pill ${user.isSuspended ? 'suspended' : 'active'}`}>
|
||||
{user.isSuspended ? 'suspended' : 'active'}
|
||||
</span>
|
||||
<span className={`status-pill ${user.isSilenced ? 'silenced' : 'visible'}`}>
|
||||
{user.isSilenced ? 'silenced' : 'visible'}
|
||||
</span>
|
||||
<span className="admin-row-meta">
|
||||
@{user.handle} • {formatDate(user.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-row-body" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{user.displayName || user.handle}
|
||||
{user.isBot && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Bot size={12} />
|
||||
AI Account
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user.suspensionReason && (
|
||||
<div className="admin-row-sub">Suspension: {user.suspensionReason}</div>
|
||||
)}
|
||||
{user.silenceReason && (
|
||||
<div className="admin-row-sub">Silence: {user.silenceReason}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-row-actions">
|
||||
<Link href={`/@${user.handle}`} className="btn btn-ghost btn-sm">
|
||||
View
|
||||
</Link>
|
||||
{user.isSuspended ? (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleUserAction(user.id, 'unsuspend')}>
|
||||
Unsuspend
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleUserAction(user.id, 'suspend')}>
|
||||
Suspend
|
||||
</button>
|
||||
)}
|
||||
{user.isSilenced ? (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleUserAction(user.id, 'unsilence')}>
|
||||
Unsilence
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleUserAction(user.id, 'silence')}>
|
||||
Silence
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'settings' && (
|
||||
<div className="admin-card">
|
||||
<div style={{ fontWeight: 600, marginBottom: '16px', fontSize: '16px' }}>Node Settings</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">Loading settings...</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '16px', maxWidth: '600px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Node Name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={nodeSettings.name}
|
||||
onChange={e => setNodeSettings({ ...nodeSettings, name: e.target.value })}
|
||||
placeholder="My Synapsis Node"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Logo</label>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
|
||||
Replaces the default logo in the sidebar. Max width: 200px.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label className="btn btn-ghost btn-sm">
|
||||
{isUploadingLogo ? 'Uploading...' : 'Upload logo'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
disabled={isUploadingLogo}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{nodeSettings.logoUrl && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={async () => {
|
||||
const nextSettings = { ...nodeSettings, logoUrl: '' };
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
}}
|
||||
>
|
||||
Remove logo
|
||||
</button>
|
||||
)}
|
||||
{logoUploadError && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--danger)' }}>{logoUploadError}</span>
|
||||
)}
|
||||
</div>
|
||||
{nodeSettings.logoUrl && (
|
||||
<div style={{ marginTop: '8px', padding: '12px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background-secondary)' }}>
|
||||
<img
|
||||
src={nodeSettings.logoUrl}
|
||||
alt="Custom logo"
|
||||
style={{ maxWidth: '200px', maxHeight: '60px', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Favicon</label>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
|
||||
The icon shown in browser tabs. Recommended: 32x32 or 64x64 PNG.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label className="btn btn-ghost btn-sm">
|
||||
{isUploadingFavicon ? 'Uploading...' : 'Upload favicon'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/x-icon,image/svg+xml"
|
||||
onChange={handleFaviconUpload}
|
||||
disabled={isUploadingFavicon}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{nodeSettings.faviconUrl && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={async () => {
|
||||
const nextSettings = { ...nodeSettings, faviconUrl: '' };
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
}}
|
||||
>
|
||||
Remove favicon
|
||||
</button>
|
||||
)}
|
||||
{faviconUploadError && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--danger)' }}>{faviconUploadError}</span>
|
||||
)}
|
||||
</div>
|
||||
{nodeSettings.faviconUrl && (
|
||||
<div style={{ marginTop: '8px', padding: '12px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background-secondary)', display: 'inline-block' }}>
|
||||
<img
|
||||
src={nodeSettings.faviconUrl}
|
||||
alt="Custom favicon"
|
||||
style={{ width: '32px', height: '32px', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label>
|
||||
<AutoTextarea
|
||||
className="input"
|
||||
value={nodeSettings.description}
|
||||
onChange={e => setNodeSettings({ ...nodeSettings, description: e.target.value })}
|
||||
placeholder="A brief tagline for your node."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Accent Color</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={nodeSettings.accentColor}
|
||||
onChange={(e) => setNodeSettings({ ...nodeSettings, accentColor: e.target.value })}
|
||||
style={{ width: '44px', height: '36px', padding: 0, border: '1px solid var(--border)', background: 'transparent', borderRadius: '8px' }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
value={nodeSettings.accentColor}
|
||||
onChange={(e) => setNodeSettings({ ...nodeSettings, accentColor: e.target.value })}
|
||||
placeholder="#FFFFFF"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Banner image</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label className="btn btn-ghost btn-sm">
|
||||
{isUploadingBanner ? 'Uploading...' : 'Upload banner'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleBannerUpload}
|
||||
disabled={isUploadingBanner}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{bannerUploadError && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--danger)' }}>{bannerUploadError}</span>
|
||||
)}
|
||||
</div>
|
||||
{nodeSettings.bannerUrl && (
|
||||
<div style={{ marginTop: '8px', height: '120px', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--border)', position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `url(${nodeSettings.bannerUrl}) center/cover no-repeat`
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(to bottom, transparent, var(--background-secondary))'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Long Description (About)</label>
|
||||
<AutoTextarea
|
||||
className="input"
|
||||
value={nodeSettings.longDescription}
|
||||
onChange={e => setNodeSettings({ ...nodeSettings, longDescription: e.target.value })}
|
||||
placeholder="Detailed information about your node/community."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Rules</label>
|
||||
<AutoTextarea
|
||||
className="input"
|
||||
value={nodeSettings.rules}
|
||||
onChange={e => setNodeSettings({ ...nodeSettings, rules: e.target.value })}
|
||||
placeholder="Community rules and guidelines."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: nodeSettings.isNsfw ? 'rgba(239, 68, 68, 0.1)' : 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
border: nodeSettings.isNsfw ? '1px solid var(--error)' : '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', display: 'block' }}>
|
||||
NSFW Node
|
||||
</label>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-secondary)', margin: 0 }}>
|
||||
{nodeSettings.isNsfw
|
||||
? 'This node is marked as NSFW. All content will be hidden from users who haven\'t enabled NSFW viewing.'
|
||||
: 'Enable this if your node primarily hosts adult or sensitive content. All posts from this node will be treated as NSFW across the swarm.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${nodeSettings.isNsfw ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{
|
||||
background: nodeSettings.isNsfw ? 'var(--error)' : undefined,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!nodeSettings.isNsfw) {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to mark this node as NSFW?\n\n' +
|
||||
'All content from this node will be hidden from users who haven\'t enabled NSFW viewing. ' +
|
||||
'This affects the entire swarm.'
|
||||
);
|
||||
if (confirmed) {
|
||||
setNodeSettings({ ...nodeSettings, isNsfw: true });
|
||||
}
|
||||
} else {
|
||||
setNodeSettings({ ...nodeSettings, isNsfw: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nodeSettings.isNsfw ? 'Remove NSFW' : 'Mark as NSFW'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
||||
{savingSettings ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Account Export API
|
||||
*
|
||||
* Generates a ZIP archive containing the user's complete account data
|
||||
* for migration to another Synapsis node.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, verifyPassword } from '@/lib/auth';
|
||||
import { db, posts, media, follows, users, remoteFollows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// We'll use a simple in-memory zip approach
|
||||
// For production, consider using a streaming zip library
|
||||
|
||||
interface ExportManifest {
|
||||
version: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string; // Encrypted with user's password
|
||||
salt: string; // For key derivation
|
||||
iv: string; // For AES encryption
|
||||
signature: string; // Proof of ownership
|
||||
}
|
||||
|
||||
interface ExportProfile {
|
||||
displayName: string | null;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
headerUrl: string | null;
|
||||
}
|
||||
|
||||
interface ExportPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
}
|
||||
|
||||
interface ExportFollowing {
|
||||
actorUrl: string;
|
||||
handle: string;
|
||||
isRemote?: boolean;
|
||||
displayName?: string | null;
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the private key with user's password using AES-256-GCM
|
||||
*/
|
||||
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
|
||||
const salt = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// Derive key from password
|
||||
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
|
||||
|
||||
// Encrypt
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine encrypted data with auth tag
|
||||
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
|
||||
|
||||
return {
|
||||
encrypted: combined,
|
||||
salt: salt.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the manifest to prove ownership
|
||||
*/
|
||||
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
|
||||
const data = JSON.stringify(manifest);
|
||||
const sign = crypto.createSign('sha256');
|
||||
sign.update(data);
|
||||
return sign.sign(privateKey, 'base64');
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const body = await req.json();
|
||||
const { password } = body;
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: 'Password required for export' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if account has already moved
|
||||
if (user.movedTo) {
|
||||
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// Fetch user's posts
|
||||
const userPosts = await db.query.posts.findMany({
|
||||
where: eq(posts.userId, user.id),
|
||||
with: {
|
||||
media: true,
|
||||
},
|
||||
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
// Fetch user's following list (local and remote)
|
||||
const userFollowing = await db.query.follows.findMany({
|
||||
where: eq(follows.followerId, user.id),
|
||||
with: {
|
||||
following: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userRemoteFollowing = await db.query.remoteFollows.findMany({
|
||||
where: eq(remoteFollows.followerId, user.id),
|
||||
});
|
||||
|
||||
// Build export data
|
||||
const exportPosts: ExportPost[] = userPosts.map(post => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
replyToApId: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
|
||||
media: (post.media || []).map((m, idx) => ({
|
||||
filename: `${post.id}_${idx}${getExtension(m.url)}`,
|
||||
url: m.url,
|
||||
altText: m.altText,
|
||||
})),
|
||||
}));
|
||||
|
||||
const exportFollowing: ExportFollowing[] = [
|
||||
// Local follows
|
||||
...userFollowing.map(f => {
|
||||
const followingUser = f.following as { handle: string };
|
||||
return {
|
||||
actorUrl: `https://${nodeDomain}/users/${followingUser.handle}`,
|
||||
handle: followingUser.handle,
|
||||
isRemote: false,
|
||||
};
|
||||
}),
|
||||
// Remote follows
|
||||
...userRemoteFollowing.map(f => ({
|
||||
actorUrl: f.targetActorUrl,
|
||||
handle: f.targetHandle,
|
||||
isRemote: true,
|
||||
displayName: f.displayName,
|
||||
bio: f.bio,
|
||||
avatarUrl: f.avatarUrl,
|
||||
})),
|
||||
];
|
||||
|
||||
const profile: ExportProfile = {
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
headerUrl: user.headerUrl,
|
||||
};
|
||||
|
||||
// Encrypt private key
|
||||
const privateKey = user.privateKeyEncrypted || '';
|
||||
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Build manifest (without signature first)
|
||||
const manifestData: Omit<ExportManifest, 'signature'> = {
|
||||
version: '1.0',
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
sourceNode: nodeDomain,
|
||||
exportedAt: new Date().toISOString(),
|
||||
publicKey: user.publicKey,
|
||||
privateKeyEncrypted: encrypted,
|
||||
salt,
|
||||
iv,
|
||||
};
|
||||
|
||||
// Sign the manifest
|
||||
const signature = signManifest(manifestData, privateKey);
|
||||
const manifest: ExportManifest = { ...manifestData, signature };
|
||||
|
||||
// Build the export package as JSON (ZIP would require additional library)
|
||||
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
|
||||
const exportPackage = {
|
||||
manifest,
|
||||
profile,
|
||||
posts: exportPosts,
|
||||
following: exportFollowing,
|
||||
// Media URLs are included in posts, client can download them separately
|
||||
// For full ZIP export, we'd need to fetch and bundle media files
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
export: exportPackage,
|
||||
stats: {
|
||||
posts: exportPosts.length,
|
||||
following: exportFollowing.length,
|
||||
mediaFiles: exportPosts.reduce((sum, p) => sum + p.media.length, 0),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Export error:', error);
|
||||
return NextResponse.json({ error: 'Export failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getExtension(url: string): string {
|
||||
const match = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
|
||||
return match ? `.${match[1]}` : '.bin';
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Account Import API
|
||||
*
|
||||
* Imports an account from another Synapsis node using the export package.
|
||||
* Creates the user with the same DID and migrates all data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, posts, media, follows, nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
interface ImportManifest {
|
||||
version: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string;
|
||||
salt: string;
|
||||
iv: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface ImportProfile {
|
||||
displayName: string | null;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
headerUrl: string | null;
|
||||
}
|
||||
|
||||
interface ImportPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
}
|
||||
|
||||
interface ImportFollowing {
|
||||
actorUrl: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
interface ImportPackage {
|
||||
manifest: ImportManifest;
|
||||
profile: ImportProfile;
|
||||
posts: ImportPost[];
|
||||
following: ImportFollowing[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the private key using the user's password
|
||||
*/
|
||||
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
|
||||
const saltBuffer = Buffer.from(salt, 'base64');
|
||||
const ivBuffer = Buffer.from(iv, 'base64');
|
||||
const encryptedBuffer = Buffer.from(encrypted, 'base64');
|
||||
|
||||
// Separate auth tag (last 16 bytes) from encrypted data
|
||||
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
|
||||
const encryptedData = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
|
||||
|
||||
// Derive key from password
|
||||
const key = crypto.pbkdf2Sync(password, saltBuffer, 100000, 32, 'sha256');
|
||||
|
||||
// Decrypt
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the manifest signature
|
||||
*/
|
||||
function verifyManifestSignature(manifest: ImportManifest): boolean {
|
||||
try {
|
||||
const { signature, ...manifestData } = manifest;
|
||||
const data = JSON.stringify(manifestData);
|
||||
|
||||
const verify = crypto.createVerify('sha256');
|
||||
verify.update(data);
|
||||
|
||||
return verify.verify(manifest.publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
console.error('Signature verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { exportData, password, newHandle, acceptedCompliance } = body as {
|
||||
exportData: ImportPackage;
|
||||
password: string;
|
||||
newHandle: string;
|
||||
acceptedCompliance: boolean;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!exportData || !password || !newHandle) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!acceptedCompliance) {
|
||||
return NextResponse.json({
|
||||
error: 'You must accept the content compliance agreement'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const { manifest, profile, posts: importPosts, following } = exportData;
|
||||
|
||||
// Validate manifest version
|
||||
if (manifest.version !== '1.0') {
|
||||
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (!verifyManifestSignature(manifest)) {
|
||||
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Decrypt private key to verify password is correct
|
||||
let privateKey: string;
|
||||
try {
|
||||
privateKey = decryptPrivateKey(
|
||||
manifest.privateKeyEncrypted,
|
||||
password,
|
||||
manifest.salt,
|
||||
manifest.iv
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if DID already exists on this node
|
||||
const existingDid = await db.query.users.findFirst({
|
||||
where: eq(users.did, manifest.did),
|
||||
});
|
||||
|
||||
if (existingDid) {
|
||||
return NextResponse.json({
|
||||
error: 'This account has already been imported to this node'
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
// Validate handle format
|
||||
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
|
||||
return NextResponse.json({
|
||||
error: 'Handle must be 3-20 characters, alphanumeric and underscores only'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if handle is available
|
||||
const existingHandle = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handleClean),
|
||||
});
|
||||
|
||||
if (existingHandle) {
|
||||
return NextResponse.json({
|
||||
error: 'Handle is already taken on this node',
|
||||
suggestedHandle: `${handleClean}_${Math.floor(Math.random() * 1000)}`,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
|
||||
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
|
||||
|
||||
// Create the user with the same DID
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: manifest.did,
|
||||
handle: handleClean,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio,
|
||||
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
|
||||
headerUrl: profile.headerUrl,
|
||||
publicKey: manifest.publicKey,
|
||||
privateKeyEncrypted: privateKey,
|
||||
movedFrom: oldActorUrl,
|
||||
migratedAt: new Date(),
|
||||
postsCount: importPosts.length,
|
||||
}).returning();
|
||||
|
||||
// Check if this is an NSFW node and auto-enable NSFW settings
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, nodeDomain),
|
||||
});
|
||||
|
||||
if (node?.isNsfw) {
|
||||
await db.update(users)
|
||||
.set({
|
||||
nsfwEnabled: true,
|
||||
isNsfw: true
|
||||
})
|
||||
.where(eq(users.id, newUser.id));
|
||||
}
|
||||
|
||||
// Import posts
|
||||
let importedPosts = 0;
|
||||
for (const post of importPosts) {
|
||||
try {
|
||||
const [newPost] = await db.insert(posts).values({
|
||||
userId: newUser.id,
|
||||
content: post.content,
|
||||
createdAt: new Date(post.createdAt),
|
||||
apId: `https://${nodeDomain}/posts/${uuid()}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
|
||||
}).returning();
|
||||
|
||||
// Import media references (URLs point to old location for now)
|
||||
for (const mediaItem of post.media) {
|
||||
await db.insert(media).values({
|
||||
userId: newUser.id,
|
||||
postId: newPost.id,
|
||||
url: mediaItem.url, // Original URL - might need re-uploading
|
||||
altText: mediaItem.altText,
|
||||
});
|
||||
}
|
||||
|
||||
importedPosts++;
|
||||
} catch (error) {
|
||||
console.error('Failed to import post:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update handle registry
|
||||
await upsertHandleEntries([{
|
||||
handle: handleClean,
|
||||
did: manifest.did,
|
||||
nodeDomain,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}]);
|
||||
|
||||
// Notify old node about the migration
|
||||
try {
|
||||
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to notify old node:', error);
|
||||
// Don't fail the import if notification fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: newUser.id,
|
||||
did: newUser.did,
|
||||
handle: newUser.handle,
|
||||
displayName: newUser.displayName,
|
||||
},
|
||||
stats: {
|
||||
postsImported: importedPosts,
|
||||
followingToRestore: following.length,
|
||||
},
|
||||
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
return NextResponse.json({ error: 'Import failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the old node that the account has moved
|
||||
*/
|
||||
async function notifyOldNode(
|
||||
sourceNode: string,
|
||||
oldHandle: string,
|
||||
newActorUrl: string,
|
||||
did: string,
|
||||
privateKey: string
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
oldHandle,
|
||||
newActorUrl,
|
||||
did,
|
||||
movedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Sign the payload
|
||||
const sign = crypto.createSign('sha256');
|
||||
sign.update(JSON.stringify(payload));
|
||||
const signature = sign.sign(privateKey, 'base64');
|
||||
|
||||
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
signature,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Old node returned ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Account Moved Notification API
|
||||
*
|
||||
* Called by the new node to notify the old node that an account has migrated.
|
||||
* The old node then marks the account as moved and broadcasts Move activity to followers.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, follows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { createMoveActivity } from '@/lib/activitypub/activities';
|
||||
import { signRequest } from '@/lib/activitypub/signatures';
|
||||
|
||||
interface MoveNotification {
|
||||
oldHandle: string;
|
||||
newActorUrl: string;
|
||||
did: string;
|
||||
movedAt: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as MoveNotification;
|
||||
const { oldHandle, newActorUrl, did, movedAt, signature } = body;
|
||||
|
||||
if (!oldHandle || !newActorUrl || !did || !signature) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the user on this node
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, oldHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify the DID matches
|
||||
if (user.did !== did) {
|
||||
return NextResponse.json({ error: 'DID mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify the signature using the user's public key
|
||||
const payload = { oldHandle, newActorUrl, did, movedAt };
|
||||
const verify = crypto.createVerify('sha256');
|
||||
verify.update(JSON.stringify(payload));
|
||||
|
||||
const isValid = verify.verify(user.publicKey, signature, 'base64');
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if already moved
|
||||
if (user.movedTo) {
|
||||
return NextResponse.json({ error: 'Account already marked as moved' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Mark the account as moved
|
||||
await db.update(users)
|
||||
.set({
|
||||
movedTo: newActorUrl,
|
||||
migratedAt: new Date(movedAt),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Get all followers to notify
|
||||
const userFollowers = await db.query.follows.findMany({
|
||||
where: eq(follows.followingId, user.id),
|
||||
with: {
|
||||
follower: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const oldActorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
// Create Move activity with DID extension
|
||||
const moveActivity = createMoveActivity(
|
||||
user,
|
||||
oldActorUrl,
|
||||
newActorUrl,
|
||||
nodeDomain
|
||||
);
|
||||
|
||||
// Broadcast Move activity to all followers
|
||||
// In a production system, this would be queued
|
||||
let notifiedCount = 0;
|
||||
for (const follow of userFollowers) {
|
||||
try {
|
||||
// For local Synapsis followers, we could update directly
|
||||
// For remote followers, we'd send the Move activity
|
||||
// For now, we'll just count them
|
||||
notifiedCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to notify follower:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Account ${oldHandle} marked as moved to ${newActorUrl}. Notified ${notifiedCount} followers.`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Account marked as moved',
|
||||
followersNotified: notifiedCount,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Move notification error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process move notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Password Change API
|
||||
*
|
||||
* Updates the user's password and re-encrypts their private key.
|
||||
* CRITICAL: Must prevent data loss by properly re-encrypting the private key.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, verifyPassword, hashPassword } from '@/lib/auth';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Decrypt the private key using the OLD password
|
||||
*/
|
||||
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
|
||||
try {
|
||||
const saltBuffer = Buffer.from(salt, 'base64');
|
||||
const ivBuffer = Buffer.from(iv, 'base64');
|
||||
const encryptedBuffer = Buffer.from(encrypted, 'base64');
|
||||
|
||||
// Separate auth tag from encrypted data
|
||||
// AES-GCM usually appends 16-byte auth tag
|
||||
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
|
||||
const encryptedData = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
|
||||
|
||||
// Derive key from password
|
||||
const key = crypto.pbkdf2Sync(password, saltBuffer, 100000, 32, 'sha256');
|
||||
|
||||
// Decrypt
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.error('Decryption failed:', error);
|
||||
throw new Error('Failed to decrypt private key with current password');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the private key with the NEW password
|
||||
*/
|
||||
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
|
||||
const salt = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// Derive key from password
|
||||
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
|
||||
|
||||
// Encrypt
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine encrypted data with auth tag
|
||||
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
|
||||
|
||||
return {
|
||||
encrypted: combined,
|
||||
salt: salt.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await req.json();
|
||||
const { currentPassword, newPassword } = body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Incorrect current password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch full user record to get encrypted key details
|
||||
// The user object from requireAuth might not have all fields if it came from session??
|
||||
// Actually requireAuth fetches from DB, but let's be safe and fetch specific fields we need
|
||||
// assuming user model in schema has moved a bit.
|
||||
// Wait, schema has `privateKeyEncrypted` as a single text field?
|
||||
// Let's check the schema again.
|
||||
// Looking at export route, I see `privateKeyEncrypted` is storing the JSON string of {encrypted, salt, iv}??
|
||||
// No, wait. In `src/lib/activitypub/signatures.ts` key generation...
|
||||
// Let's look at how it's stored.
|
||||
|
||||
/*
|
||||
Checking `src/app/api/auth/register/route.ts` would be ideal, but I don't have it open.
|
||||
In `src/app/api/account/export/route.ts`, I implemented encryption using:
|
||||
encryptPrivateKey(privateKey, password) returning { encrypted, salt, iv }
|
||||
|
||||
BUT the database schema `users` table has:
|
||||
privateKeyEncrypted: text('private_key_encrypted'),
|
||||
|
||||
I need to know how it's stored in the DB. Is it a JSON string?
|
||||
Or perhaps `privateKeyEncrypted` is JUST the base64 string and salt/iv are stored elsewhere?
|
||||
|
||||
Let's check `src/app/api/auth/register/route.ts` OR how I used it in export.
|
||||
In export route I WROTE `encryptPrivateKey` myself.
|
||||
|
||||
Let's look at `src/db/schema.ts` lines 36-37:
|
||||
privateKeyEncrypted: text('private_key_encrypted'),
|
||||
|
||||
If I look at `import` route:
|
||||
It takes the exported JSON (which has separated fields) and creates the user.
|
||||
const [newUser] = await db.insert(users).values({ ... privateKeyEncrypted: privateKey ... })
|
||||
Wait, in import route I decrypt it using the password, then I insert it...
|
||||
WAIT. The import route inserts `privateKeyEncrypted: privateKey`.
|
||||
This implies `privateKeyEncrypted` column implies it SHOULD be encrypted, but if I'm inserting the RAW private key there... that's bad.
|
||||
|
||||
Let's verify `src/app/api/account/import/route.ts`.
|
||||
*/
|
||||
|
||||
// I'll proceed assuming I need to store it encrypted.
|
||||
// If the current implementation stores it as a JSON string containing { cyphertext, salt, iv }, I should maintain that.
|
||||
// Let's assume standard storage format is JSON stringified { encrypted, salt, iv } based on my Export/Import implementation pattern
|
||||
// (even though Import seemed to decrypt and then insert... which might mean it's storing raw?? I hope not).
|
||||
|
||||
// Let's assume for now that I need to re-encrypt.
|
||||
// If `user.privateKeyEncrypted` is a string, let's try to parse it.
|
||||
|
||||
let privateKey: string;
|
||||
|
||||
// We'll define a type for the stored format
|
||||
type StoredKey = { encrypted: string; salt: string; iv: string };
|
||||
|
||||
if (!user.privateKeyEncrypted) {
|
||||
return NextResponse.json({ error: 'No private key found to re-encrypt' }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to parse if it's JSON
|
||||
let stored: StoredKey;
|
||||
|
||||
// Check if it's already an object or string
|
||||
if (typeof user.privateKeyEncrypted === 'string' && user.privateKeyEncrypted.startsWith('{')) {
|
||||
stored = JSON.parse(user.privateKeyEncrypted);
|
||||
} else {
|
||||
// If it's not JSON, maybe it's raw? Or using a different scheme?
|
||||
// This is risky. If I can't decrypt it, I can't change the password safely without losing the key.
|
||||
// For now, let's assume it follows the JSON pattern I established.
|
||||
|
||||
// FALLBACK Validation checks would be good here.
|
||||
throw new Error('Unknown private key format');
|
||||
}
|
||||
|
||||
privateKey = decryptPrivateKey(stored.encrypted, currentPassword, stored.salt, stored.iv);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Key decryption error:', e);
|
||||
// If we can't decrypt, we CANNOT proceed with password change because we'd lose the key.
|
||||
return NextResponse.json({ error: 'Failed to unlock secure key storage with current password' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Now encrypt with new password
|
||||
const newKeyData = encryptPrivateKey(privateKey, newPassword);
|
||||
const newStoredKey = JSON.stringify(newKeyData);
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await hashPassword(newPassword);
|
||||
|
||||
// Update user
|
||||
await db.update(users)
|
||||
.set({
|
||||
passwordHash: newPasswordHash,
|
||||
privateKeyEncrypted: newStoredKey,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Password updated successfully' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
return NextResponse.json({ error: 'Failed to change password' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { isAdminUser } from '@/lib/auth/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ isAdmin: false, user: null });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ isAdmin: false, user: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isAdmin: isAdminUser(session.user),
|
||||
user: {
|
||||
id: session.user.id,
|
||||
handle: session.user.handle,
|
||||
displayName: session.user.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin status error:', error);
|
||||
return NextResponse.json({ isAdmin: false, user: null });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const data = await req.json();
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
let node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
[node] = await db.insert(nodes).values({
|
||||
domain,
|
||||
name: data.name || process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||
description: data.description,
|
||||
longDescription: data.longDescription,
|
||||
rules: data.rules,
|
||||
bannerUrl: data.bannerUrl,
|
||||
logoUrl: data.logoUrl,
|
||||
faviconUrl: data.faviconUrl,
|
||||
accentColor: data.accentColor,
|
||||
isNsfw: data.isNsfw ?? false,
|
||||
}).returning();
|
||||
} else {
|
||||
[node] = await db.update(nodes)
|
||||
.set({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
longDescription: data.longDescription,
|
||||
rules: data.rules,
|
||||
bannerUrl: data.bannerUrl,
|
||||
logoUrl: data.logoUrl,
|
||||
faviconUrl: data.faviconUrl,
|
||||
accentColor: data.accentColor,
|
||||
isNsfw: data.isNsfw ?? node.isNsfw,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nodes.id, node.id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
return NextResponse.json({ node });
|
||||
} catch (error) {
|
||||
console.error('Update node settings error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const moderationSchema = z.object({
|
||||
action: z.enum(['remove', 'restore']),
|
||||
reason: z.string().max(240).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = moderationSchema.parse(body);
|
||||
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, id),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (data.action === 'remove') {
|
||||
const [updated] = await db.update(posts)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedAt: new Date(),
|
||||
removedBy: admin.id,
|
||||
removedReason: data.reason || null,
|
||||
})
|
||||
.where(eq(posts.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ post: updated });
|
||||
}
|
||||
|
||||
const [restored] = await db.update(posts)
|
||||
.set({
|
||||
isRemoved: false,
|
||||
removedAt: null,
|
||||
removedBy: null,
|
||||
removedReason: null,
|
||||
})
|
||||
.where(eq(posts.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ post: restored });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Post moderation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'active'; // active | removed | all
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const where =
|
||||
status === 'active'
|
||||
? eq(posts.isRemoved, false)
|
||||
: status === 'removed'
|
||||
? eq(posts.isRemoved, true)
|
||||
: undefined;
|
||||
|
||||
const results = await db.query.posts.findMany({
|
||||
where,
|
||||
with: {
|
||||
author: true,
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
const sanitized = results.map((post) => {
|
||||
const author = post.author as { id: string; handle: string; displayName: string | null };
|
||||
return {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
isRemoved: post.isRemoved,
|
||||
removedReason: post.removedReason,
|
||||
author: {
|
||||
id: author.id,
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ posts: sanitized });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin posts error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const updateSchema = z.object({
|
||||
status: z.enum(['open', 'resolved']),
|
||||
note: z.string().max(240).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = updateSchema.parse(body);
|
||||
|
||||
const report = await db.query.reports.findFirst({
|
||||
where: eq(reports.id, id),
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const [updated] = await db.update(reports)
|
||||
.set({
|
||||
status: data.status,
|
||||
resolvedAt: data.status === 'resolved' ? new Date() : null,
|
||||
resolvedBy: data.status === 'resolved' ? admin.id : null,
|
||||
resolutionNote: data.note || null,
|
||||
})
|
||||
.where(eq(reports.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ report: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Report update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update report' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports, posts, users } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { desc, inArray, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'open'; // open | resolved | all
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const reportRows = await db.query.reports.findMany({
|
||||
where: status === 'all' ? undefined : eq(reports.status, status),
|
||||
orderBy: [desc(reports.createdAt)],
|
||||
limit,
|
||||
with: {
|
||||
reporter: true,
|
||||
resolver: true,
|
||||
},
|
||||
});
|
||||
|
||||
const postIds = reportRows
|
||||
.filter((report) => report.targetType === 'post')
|
||||
.map((report) => report.targetId);
|
||||
const userIds = reportRows
|
||||
.filter((report) => report.targetType === 'user')
|
||||
.map((report) => report.targetId);
|
||||
|
||||
const postTargetsRaw = postIds.length
|
||||
? await db.query.posts.findMany({
|
||||
where: inArray(posts.id, postIds),
|
||||
with: { author: true },
|
||||
})
|
||||
: [];
|
||||
const userTargetsRaw = userIds.length
|
||||
? await db.query.users.findMany({
|
||||
where: inArray(users.id, userIds),
|
||||
})
|
||||
: [];
|
||||
|
||||
const postTargets = postTargetsRaw.map((post) => {
|
||||
const author = post.author as { id: string; handle: string; displayName: string | null };
|
||||
return {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
isRemoved: post.isRemoved,
|
||||
author: {
|
||||
id: author.id,
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const userTargets = userTargetsRaw.map((user) => ({
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
isSuspended: user.isSuspended,
|
||||
isSilenced: user.isSilenced,
|
||||
}));
|
||||
|
||||
const postMap = new Map(postTargets.map((post) => [post.id, post]));
|
||||
const userMap = new Map(userTargets.map((user) => [user.id, user]));
|
||||
|
||||
type UserInfo = { id: string; handle: string };
|
||||
|
||||
const reportsWithTargets = reportRows.map((report) => {
|
||||
const reporter = report.reporter as UserInfo | null;
|
||||
const resolver = report.resolver as UserInfo | null;
|
||||
return {
|
||||
id: report.id,
|
||||
targetType: report.targetType,
|
||||
targetId: report.targetId,
|
||||
reason: report.reason,
|
||||
status: report.status,
|
||||
createdAt: report.createdAt,
|
||||
reporter: reporter
|
||||
? { id: reporter.id, handle: reporter.handle }
|
||||
: null,
|
||||
resolver: resolver
|
||||
? { id: resolver.id, handle: resolver.handle }
|
||||
: null,
|
||||
target:
|
||||
report.targetType === 'post'
|
||||
? postMap.get(report.targetId) || null
|
||||
: userMap.get(report.targetId) || null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ reports: reportsWithTargets });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin reports error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch reports' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const moderationSchema = z.object({
|
||||
action: z.enum(['suspend', 'unsuspend', 'silence', 'unsilence']),
|
||||
reason: z.string().max(240).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = moderationSchema.parse(body);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.id === admin.id && (data.action === 'suspend' || data.action === 'silence')) {
|
||||
return NextResponse.json({ error: 'Cannot apply this action to yourself' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (data.action === 'suspend') {
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSuspended: true,
|
||||
suspendedAt: new Date(),
|
||||
suspensionReason: data.reason || null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
}
|
||||
|
||||
if (data.action === 'unsuspend') {
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSuspended: false,
|
||||
suspendedAt: null,
|
||||
suspensionReason: null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
}
|
||||
|
||||
if (data.action === 'silence') {
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSilenced: true,
|
||||
silencedAt: new Date(),
|
||||
silenceReason: data.reason || null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
}
|
||||
|
||||
const [updated] = await db.update(users)
|
||||
.set({
|
||||
isSilenced: false,
|
||||
silencedAt: null,
|
||||
silenceReason: null,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ user: {
|
||||
id: updated.id,
|
||||
handle: updated.handle,
|
||||
displayName: updated.displayName,
|
||||
email: updated.email,
|
||||
isSuspended: updated.isSuspended,
|
||||
suspensionReason: updated.suspensionReason,
|
||||
isSilenced: updated.isSilenced,
|
||||
silenceReason: updated.silenceReason,
|
||||
createdAt: updated.createdAt,
|
||||
} });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin user moderation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const results = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
email: users.email,
|
||||
isSuspended: users.isSuspended,
|
||||
suspensionReason: users.suspensionReason,
|
||||
isSilenced: users.isSilenced,
|
||||
silenceReason: users.silenceReason,
|
||||
createdAt: users.createdAt,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({ users: results });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Admin users error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const handle = searchParams.get('handle')?.toLowerCase().trim();
|
||||
|
||||
if (!handle || handle.length < 3) {
|
||||
return NextResponse.json({ available: false, error: 'Handle too short' });
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(handle)) {
|
||||
return NextResponse.json({ available: false, error: 'Invalid characters' });
|
||||
}
|
||||
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
available: !existingUser,
|
||||
handle
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Check handle error:', error);
|
||||
return NextResponse.json({ error: 'Failed to check handle' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authenticateUser, createSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = loginSchema.parse(body);
|
||||
|
||||
const user = await authenticateUser(data.email, data.password);
|
||||
|
||||
// Create session
|
||||
await createSession(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Login failed' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { destroySession } from '@/lib/auth';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await destroySession();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Logout failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSession, requireAuth } from '@/lib/auth';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
displayName: z.string().min(1).max(50).optional(),
|
||||
bio: z.string().max(160).optional().nullable(),
|
||||
avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
website: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Return null user if no database is connected (for UI testing)
|
||||
if (!db) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: session.user.id,
|
||||
handle: session.user.handle,
|
||||
displayName: session.user.displayName,
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
bio: session.user.bio,
|
||||
website: session.user.website,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Session check error:', error);
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const currentUser = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = updateProfileSchema.parse(body);
|
||||
|
||||
const updateData: {
|
||||
displayName?: string;
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
headerUrl?: string | null;
|
||||
website?: string | null;
|
||||
updatedAt?: Date;
|
||||
} = {};
|
||||
|
||||
if (data.displayName !== undefined) updateData.displayName = data.displayName;
|
||||
if (data.bio !== undefined) updateData.bio = data.bio === '' ? null : data.bio;
|
||||
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
|
||||
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
|
||||
if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: currentUser.id,
|
||||
handle: currentUser.handle,
|
||||
displayName: currentUser.displayName,
|
||||
avatarUrl: currentUser.avatarUrl,
|
||||
bio: currentUser.bio,
|
||||
headerUrl: currentUser.headerUrl,
|
||||
website: currentUser.website,
|
||||
followersCount: currentUser.followersCount,
|
||||
followingCount: currentUser.followingCount,
|
||||
postsCount: currentUser.postsCount,
|
||||
createdAt: currentUser.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateData.updatedAt = new Date();
|
||||
|
||||
const [updatedUser] = await db.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, currentUser.id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
handle: updatedUser.handle,
|
||||
displayName: updatedUser.displayName,
|
||||
avatarUrl: updatedUser.avatarUrl,
|
||||
bio: updatedUser.bio,
|
||||
headerUrl: updatedUser.headerUrl,
|
||||
website: updatedUser.website,
|
||||
followersCount: updatedUser.followersCount,
|
||||
followingCount: updatedUser.followingCount,
|
||||
postsCount: updatedUser.postsCount,
|
||||
createdAt: updatedUser.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Profile update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { registerUser, createSession } from '@/lib/auth';
|
||||
import { db, nodes, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = registerSchema.parse(body);
|
||||
|
||||
const user = await registerUser(
|
||||
data.handle,
|
||||
data.email,
|
||||
data.password,
|
||||
data.displayName
|
||||
);
|
||||
|
||||
// Check if this is an NSFW node and auto-enable NSFW settings
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, nodeDomain),
|
||||
});
|
||||
|
||||
if (node?.isNsfw) {
|
||||
// Auto-enable NSFW viewing and mark account as NSFW for users on NSFW nodes
|
||||
await db.update(users)
|
||||
.set({
|
||||
nsfwEnabled: true,
|
||||
isNsfw: true
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
// Create session for new user
|
||||
await createSession(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Registration failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Bot API Key Management Routes
|
||||
*
|
||||
* POST /api/bots/[id]/api-key - Set/update LLM API key
|
||||
* DELETE /api/bots/[id]/api-key - Remove API key
|
||||
*
|
||||
* Requirements: 2.1, 2.2, 2.4
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getBotById,
|
||||
userOwnsBot,
|
||||
setApiKey,
|
||||
removeApiKey,
|
||||
BotNotFoundError,
|
||||
BotValidationError,
|
||||
} from '@/lib/bots/botManager';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Schema for setting API key
|
||||
const setApiKeySchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/bots/[id]/api-key - Set/update LLM API key
|
||||
*
|
||||
* Requires authentication.
|
||||
* Sets or updates the API key for the bot's LLM provider.
|
||||
* The API key is validated and encrypted before storage.
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.2, 2.4
|
||||
*/
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = setApiKeySchema.parse(body);
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Set the API key (validates format and encrypts)
|
||||
await setApiKey(id, data.apiKey, data.provider);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'API key updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Set API key error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof BotValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to set API key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/bots/[id]/api-key - Remove API key
|
||||
*
|
||||
* Requires authentication.
|
||||
* Removes the API key from the bot, disabling LLM functionality.
|
||||
*
|
||||
* Note: Since the llmApiKeyEncrypted field is NOT NULL in the schema,
|
||||
* this sets the key to an empty encrypted value, effectively disabling it.
|
||||
*
|
||||
* Validates: Requirements 2.4
|
||||
*/
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Remove the API key
|
||||
await removeApiKey(id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'API key removed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove API key error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove API key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Bot API Key Status Route
|
||||
*
|
||||
* GET /api/bots/[id]/api-key/status - Check if API key is configured
|
||||
*
|
||||
* Requirements: 2.1, 2.2
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import {
|
||||
getBotById,
|
||||
userOwnsBot,
|
||||
getApiKeyStatus,
|
||||
BotNotFoundError,
|
||||
} from '@/lib/bots/botManager';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/bots/[id]/api-key/status - Check if API key is configured
|
||||
*
|
||||
* Requires authentication.
|
||||
* Returns whether an API key is configured for the bot (not the key itself).
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.2
|
||||
*/
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get API key status
|
||||
const status = await getApiKeyStatus(id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get API key status error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get API key status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Bot Error Logs API Route
|
||||
*
|
||||
* GET /api/bots/[id]/logs/errors - Get error logs only
|
||||
*
|
||||
* Requirements: 8.6
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, bots } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getErrorLogs } from '@/lib/bots/activityLogger';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: botId } = await params;
|
||||
|
||||
// Verify bot exists and user owns it
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
columns: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (bot.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authorized' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse limit
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 50;
|
||||
|
||||
// Get error logs
|
||||
const logs = await getErrorLogs(botId, limit);
|
||||
|
||||
return NextResponse.json({ logs, count: logs.length });
|
||||
} catch (error) {
|
||||
console.error('Error fetching error logs:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch error logs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Bot Activity Logs API Route
|
||||
*
|
||||
* GET /api/bots/[id]/logs - Get activity logs with filters
|
||||
*
|
||||
* Requirements: 8.2, 8.6
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, bots } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getLogsForBot, type ActionType } from '@/lib/bots/activityLogger';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: botId } = await params;
|
||||
|
||||
// Verify bot exists and user owns it
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
columns: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (bot.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authorized' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = new URL(request.url);
|
||||
const actionTypes = searchParams.get('actionTypes')?.split(',') as ActionType[] | undefined;
|
||||
const startDate = searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined;
|
||||
const endDate = searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined;
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
|
||||
const offset = searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : undefined;
|
||||
|
||||
// Get logs
|
||||
const logs = await getLogsForBot(botId, {
|
||||
actionTypes,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return NextResponse.json({ logs, count: logs.length });
|
||||
} catch (error) {
|
||||
console.error('Error fetching bot logs:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch logs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Bot Mention Response API Route
|
||||
*
|
||||
* POST /api/bots/[id]/mentions/[mid]/respond - Manually respond to a mention
|
||||
*
|
||||
* Requirements: 7.1
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, bots } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { processMention, getMentionById } from '@/lib/bots/mentionHandler';
|
||||
|
||||
/**
|
||||
* POST /api/bots/[id]/mentions/[mid]/respond
|
||||
*
|
||||
* Manually trigger a response to a specific mention.
|
||||
* Checks rate limits and generates a reply using the bot's LLM.
|
||||
*
|
||||
* Validates: Requirements 7.1
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; mid: string }> }
|
||||
) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: botId, mid: mentionId } = await params;
|
||||
|
||||
// Verify bot exists and user owns it
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
isSuspended: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (bot.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authorized to access this bot' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (bot.isSuspended) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot is suspended' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify mention exists and belongs to this bot
|
||||
const mention = await getMentionById(mentionId);
|
||||
|
||||
if (!mention) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Mention not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (mention.botId !== botId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Mention does not belong to this bot' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process the mention
|
||||
const result = await processMention(mentionId);
|
||||
|
||||
if (!result.success) {
|
||||
// Check if it's a rate limit error
|
||||
if (result.error?.includes('rate limit') || result.error?.includes('Rate limit')) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to process mention' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
responsePostId: result.responsePostId,
|
||||
message: 'Reply posted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error responding to mention:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to respond to mention' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Bot Mentions API Routes
|
||||
*
|
||||
* GET /api/bots/[id]/mentions - Get pending mentions for a bot
|
||||
*
|
||||
* Requirements: 7.1
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, bots } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getUnprocessedMentions, getAllMentions } from '@/lib/bots/mentionHandler';
|
||||
|
||||
/**
|
||||
* GET /api/bots/[id]/mentions
|
||||
*
|
||||
* Get mentions for a bot. Returns unprocessed mentions by default,
|
||||
* or all mentions if ?all=true is provided.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - all: boolean - If true, return all mentions (processed and unprocessed)
|
||||
*
|
||||
* Validates: Requirements 7.1
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: botId } = await params;
|
||||
|
||||
// Verify bot exists and user owns it
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (bot.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authorized to access this bot' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get mentions based on query parameter
|
||||
const { searchParams } = new URL(request.url);
|
||||
const showAll = searchParams.get('all') === 'true';
|
||||
|
||||
const mentions = showAll
|
||||
? await getAllMentions(botId)
|
||||
: await getUnprocessedMentions(botId);
|
||||
|
||||
return NextResponse.json({
|
||||
mentions,
|
||||
count: mentions.length,
|
||||
unprocessedOnly: !showAll,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching bot mentions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch mentions' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Bot Operations API Route Tests
|
||||
*
|
||||
* Tests for POST /api/bots/[id]/post
|
||||
*
|
||||
* Requirements: 5.4
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { POST } from './route';
|
||||
import * as auth from '@/lib/auth';
|
||||
import * as botManager from '@/lib/bots/botManager';
|
||||
import * as posting from '@/lib/bots/posting';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('@/lib/auth');
|
||||
vi.mock('@/lib/bots/botManager');
|
||||
vi.mock('@/lib/bots/posting');
|
||||
|
||||
describe('POST /api/bots/[id]/post', () => {
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
handle: 'testuser',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockBot = {
|
||||
id: 'bot-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Bot',
|
||||
handle: 'testbot',
|
||||
isActive: true,
|
||||
isSuspended: false,
|
||||
};
|
||||
|
||||
const mockPost = {
|
||||
id: 'post-123',
|
||||
userId: 'user-123',
|
||||
content: 'Test post content',
|
||||
apId: 'https://example.com/posts/post-123',
|
||||
apUrl: 'https://example.com/posts/post-123',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockContentItem = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
sourceId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
title: 'Test Article',
|
||||
content: 'Test content',
|
||||
url: 'https://example.com/article',
|
||||
publishedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(auth.requireAuth).mockResolvedValue(mockUser as any);
|
||||
});
|
||||
|
||||
it('should successfully trigger a post', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Mock successful post creation
|
||||
vi.mocked(posting.triggerPost).mockResolvedValue({
|
||||
success: true,
|
||||
post: mockPost as any,
|
||||
contentItem: mockContentItem,
|
||||
});
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.post).toBeDefined();
|
||||
expect(data.post.id).toBe('post-123');
|
||||
expect(data.contentItem).toBeDefined();
|
||||
expect(data.contentItem.id).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
|
||||
// Verify triggerPost was called correctly
|
||||
expect(posting.triggerPost).toHaveBeenCalledWith('bot-123', {
|
||||
sourceContentId: undefined,
|
||||
context: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger a post with specific content ID', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Mock successful post creation
|
||||
vi.mocked(posting.triggerPost).mockResolvedValue({
|
||||
success: true,
|
||||
post: mockPost as any,
|
||||
contentItem: mockContentItem,
|
||||
});
|
||||
|
||||
// Create request with content ID
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceContentId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
context: 'Test context',
|
||||
}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
// Verify triggerPost was called with correct options
|
||||
expect(posting.triggerPost).toHaveBeenCalledWith('bot-123', {
|
||||
sourceContentId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
context: 'Test context',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
// Mock authentication failure
|
||||
vi.mocked(auth.requireAuth).mockRejectedValue(new Error('Authentication required'));
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 403 if user does not own the bot', async () => {
|
||||
// Mock ownership check - user does not own bot
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(false);
|
||||
vi.mocked(botManager.getBotById).mockResolvedValue(mockBot as any);
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('Not authorized');
|
||||
});
|
||||
|
||||
it('should return 404 if bot does not exist', async () => {
|
||||
// Mock ownership check - bot not found
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(false);
|
||||
vi.mocked(botManager.getBotById).mockResolvedValue(null);
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe('Bot not found');
|
||||
});
|
||||
|
||||
it('should return 429 if rate limited', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Mock rate limit error
|
||||
vi.mocked(posting.triggerPost).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Rate limit exceeded',
|
||||
errorCode: 'RATE_LIMITED',
|
||||
});
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(429);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('Rate limit exceeded');
|
||||
expect(data.errorCode).toBe('RATE_LIMITED');
|
||||
});
|
||||
|
||||
it('should return 403 if bot is suspended', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Mock suspended bot error
|
||||
vi.mocked(posting.triggerPost).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Bot is suspended: Violation of terms',
|
||||
errorCode: 'BOT_SUSPENDED',
|
||||
});
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('BOT_SUSPENDED');
|
||||
});
|
||||
|
||||
it('should return 422 if no content available', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Mock no content error
|
||||
vi.mocked(posting.triggerPost).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'No content available for posting',
|
||||
errorCode: 'NO_CONTENT',
|
||||
});
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NO_CONTENT');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid input', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Create request with invalid UUID
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceContentId: 'invalid-uuid',
|
||||
}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('Invalid input');
|
||||
expect(data.details).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 500 for generation failures', async () => {
|
||||
// Mock ownership check
|
||||
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
|
||||
|
||||
// Mock generation failure
|
||||
vi.mocked(posting.triggerPost).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Failed to generate post content',
|
||||
errorCode: 'GENERATION_FAILED',
|
||||
});
|
||||
|
||||
// Create request
|
||||
const request = new Request('http://localhost/api/bots/bot-123/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const context = {
|
||||
params: Promise.resolve({ id: 'bot-123' }),
|
||||
};
|
||||
|
||||
// Call the route
|
||||
const response = await POST(request, context);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('GENERATION_FAILED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Bot Operations API Routes
|
||||
*
|
||||
* POST /api/bots/[id]/post - Manual post trigger
|
||||
*
|
||||
* Requirements: 5.4
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getBotById,
|
||||
userOwnsBot,
|
||||
BotNotFoundError,
|
||||
} from '@/lib/bots/botManager';
|
||||
import {
|
||||
triggerPost,
|
||||
type TriggerPostOptions,
|
||||
type PostCreationErrorCode,
|
||||
} from '@/lib/bots/posting';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Schema for manual post trigger
|
||||
const triggerPostSchema = z.object({
|
||||
sourceContentId: z.string().uuid().optional(),
|
||||
context: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/bots/[id]/post - Manually trigger a post
|
||||
*
|
||||
* Requires authentication.
|
||||
* Triggers a post for the bot if the user owns the bot.
|
||||
*
|
||||
* Request body:
|
||||
* - sourceContentId (optional): Specific content item ID to post about
|
||||
* - context (optional): Additional context for the post
|
||||
*
|
||||
* Response:
|
||||
* - success: Whether the post was created successfully
|
||||
* - post: The created post (if successful)
|
||||
* - error: Error message (if failed)
|
||||
* - errorCode: Error code (if failed)
|
||||
*
|
||||
* Validates: Requirements 5.4
|
||||
*/
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const data = triggerPostSchema.parse(body);
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build trigger options
|
||||
const options: TriggerPostOptions = {
|
||||
sourceContentId: data.sourceContentId,
|
||||
context: data.context,
|
||||
};
|
||||
|
||||
// Trigger the post
|
||||
const result = await triggerPost(id, options);
|
||||
|
||||
// Handle result
|
||||
if (!result.success) {
|
||||
// Map error codes to HTTP status codes
|
||||
const statusCode = getStatusCodeForError(result.errorCode);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
errorCode: result.errorCode,
|
||||
},
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// Return success response
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
post: {
|
||||
id: result.post!.id,
|
||||
userId: result.post!.userId,
|
||||
content: result.post!.content,
|
||||
apId: result.post!.apId,
|
||||
apUrl: result.post!.apUrl,
|
||||
createdAt: result.post!.createdAt,
|
||||
},
|
||||
contentItem: result.contentItem ? {
|
||||
id: result.contentItem.id,
|
||||
title: result.contentItem.title,
|
||||
url: result.contentItem.url,
|
||||
} : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Trigger post error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to trigger post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error codes to HTTP status codes.
|
||||
*
|
||||
* @param errorCode - The error code from post creation
|
||||
* @returns HTTP status code
|
||||
*/
|
||||
function getStatusCodeForError(errorCode?: PostCreationErrorCode): number {
|
||||
switch (errorCode) {
|
||||
case 'BOT_NOT_FOUND':
|
||||
case 'CONTENT_NOT_FOUND':
|
||||
return 404;
|
||||
|
||||
case 'BOT_SUSPENDED':
|
||||
case 'BOT_INACTIVE':
|
||||
return 403;
|
||||
|
||||
case 'NO_API_KEY':
|
||||
case 'VALIDATION_FAILED':
|
||||
return 400;
|
||||
|
||||
case 'RATE_LIMITED':
|
||||
return 429;
|
||||
|
||||
case 'NO_CONTENT':
|
||||
return 422; // Unprocessable Entity
|
||||
|
||||
case 'GENERATION_FAILED':
|
||||
case 'DATABASE_ERROR':
|
||||
case 'FEDERATION_ERROR':
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Bot Reinstatement API Route
|
||||
*
|
||||
* POST /api/bots/[id]/reinstate - Reinstate a suspended bot (admin only)
|
||||
*
|
||||
* Requirements: 10.6
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, bots } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { reinstateBot } from '@/lib/bots/suspension';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Add admin check here
|
||||
|
||||
const { id: botId } = await params;
|
||||
|
||||
// Verify bot exists
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
columns: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Reinstate the bot
|
||||
const reinstatedBot = await reinstateBot(botId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bot: {
|
||||
id: reinstatedBot.id,
|
||||
isSuspended: reinstatedBot.isSuspended,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reinstating bot:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reinstate bot' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Bot Detail API Routes
|
||||
*
|
||||
* GET /api/bots/[id] - Get bot details
|
||||
* PUT /api/bots/[id] - Update bot
|
||||
* DELETE /api/bots/[id] - Delete bot
|
||||
*
|
||||
* Requirements: 1.1, 1.3, 1.4
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getBotById,
|
||||
updateBot,
|
||||
deleteBot,
|
||||
userOwnsBot,
|
||||
BotNotFoundError,
|
||||
BotValidationError,
|
||||
} from '@/lib/bots/botManager';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Schema for updating a bot
|
||||
const updateBotSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
bio: z.string().max(500).optional().nullable(),
|
||||
avatarUrl: z.string().url().optional().nullable(),
|
||||
headerUrl: z.string().url().optional().nullable(),
|
||||
personality: z.object({
|
||||
systemPrompt: z.string().min(1).max(10000),
|
||||
temperature: z.number().min(0).max(2),
|
||||
maxTokens: z.number().int().min(1).max(100000),
|
||||
responseStyle: z.string().optional(),
|
||||
}).optional(),
|
||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
|
||||
llmModel: z.string().min(1).optional(),
|
||||
llmApiKey: z.string().min(1).optional(),
|
||||
schedule: z.object({
|
||||
type: z.enum(['interval', 'times', 'cron']),
|
||||
intervalMinutes: z.number().int().min(5).optional(),
|
||||
times: z.array(z.string().regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/)).optional(),
|
||||
cronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
}).optional().nullable(),
|
||||
autonomousMode: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/bots/[id] - Get bot details
|
||||
*
|
||||
* Requires authentication.
|
||||
* Returns the bot details if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 1.3
|
||||
*/
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Return bot without sensitive data (API keys)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bot: {
|
||||
id: bot.id,
|
||||
userId: bot.userId,
|
||||
name: bot.name,
|
||||
handle: bot.handle,
|
||||
bio: bot.bio,
|
||||
avatarUrl: bot.avatarUrl,
|
||||
headerUrl: bot.headerUrl,
|
||||
personalityConfig: bot.personalityConfig,
|
||||
llmProvider: bot.llmProvider,
|
||||
llmModel: bot.llmModel,
|
||||
scheduleConfig: bot.scheduleConfig,
|
||||
autonomousMode: bot.autonomousMode,
|
||||
isActive: bot.isActive,
|
||||
isSuspended: bot.isSuspended,
|
||||
suspensionReason: bot.suspensionReason,
|
||||
suspendedAt: bot.suspendedAt,
|
||||
publicKey: bot.publicKey,
|
||||
lastPostAt: bot.lastPostAt,
|
||||
createdAt: bot.createdAt,
|
||||
updatedAt: bot.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get bot error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get bot' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/bots/[id] - Update bot (full update)
|
||||
* PATCH /api/bots/[id] - Update bot (partial update)
|
||||
*
|
||||
* Requires authentication.
|
||||
* Updates the bot configuration if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 1.1
|
||||
*/
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
return handleUpdate(request, context);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
return handleUpdate(request, context);
|
||||
}
|
||||
|
||||
async function handleUpdate(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = updateBotSchema.parse(body);
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build update input
|
||||
const updateInput: Parameters<typeof updateBot>[1] = {};
|
||||
|
||||
if (data.name !== undefined) updateInput.name = data.name;
|
||||
if (data.bio !== undefined) updateInput.bio = data.bio ?? undefined;
|
||||
if (data.avatarUrl !== undefined) updateInput.avatarUrl = data.avatarUrl ?? undefined;
|
||||
if (data.headerUrl !== undefined) updateInput.headerUrl = data.headerUrl ?? undefined;
|
||||
if (data.personality !== undefined) updateInput.personality = data.personality;
|
||||
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
|
||||
if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel;
|
||||
if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey;
|
||||
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
|
||||
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
|
||||
if (data.isActive !== undefined) updateInput.isActive = data.isActive;
|
||||
|
||||
const updatedBot = await updateBot(id, updateInput);
|
||||
|
||||
// Return updated bot without sensitive data
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bot: {
|
||||
id: updatedBot.id,
|
||||
userId: updatedBot.userId,
|
||||
name: updatedBot.name,
|
||||
handle: updatedBot.handle,
|
||||
bio: updatedBot.bio,
|
||||
avatarUrl: updatedBot.avatarUrl,
|
||||
headerUrl: updatedBot.headerUrl,
|
||||
personalityConfig: updatedBot.personalityConfig,
|
||||
llmProvider: updatedBot.llmProvider,
|
||||
llmModel: updatedBot.llmModel,
|
||||
scheduleConfig: updatedBot.scheduleConfig,
|
||||
autonomousMode: updatedBot.autonomousMode,
|
||||
isActive: updatedBot.isActive,
|
||||
isSuspended: updatedBot.isSuspended,
|
||||
suspensionReason: updatedBot.suspensionReason,
|
||||
lastPostAt: updatedBot.lastPostAt,
|
||||
createdAt: updatedBot.createdAt,
|
||||
updatedAt: updatedBot.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update bot error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof BotValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update bot' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/bots/[id] - Delete bot
|
||||
*
|
||||
* Requires authentication.
|
||||
* Deletes the bot and all associated data if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, id);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(id);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
await deleteBot(id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Bot deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete bot error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete bot' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Content Source Fetch API Route
|
||||
*
|
||||
* POST /api/bots/[id]/sources/[sid]/fetch - Manually trigger fetch
|
||||
*
|
||||
* Requirements: 4.1, 4.6
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { userOwnsBot, getBotById } from '@/lib/bots/botManager';
|
||||
import {
|
||||
getSourceById,
|
||||
botOwnsSource,
|
||||
ContentSourceNotFoundError,
|
||||
} from '@/lib/bots/contentSource';
|
||||
import {
|
||||
fetchContentWithRetry,
|
||||
FetchResult,
|
||||
} from '@/lib/bots/contentFetcher';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; sid: string }> };
|
||||
|
||||
/**
|
||||
* POST /api/bots/[id]/sources/[sid]/fetch - Manually trigger fetch
|
||||
*
|
||||
* Requires authentication.
|
||||
* Triggers a manual content fetch for the source if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 4.1, 4.6
|
||||
*/
|
||||
export async function POST(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: botId, sid: sourceId } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, botId);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(botId);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if the source belongs to this bot
|
||||
const sourceOwned = await botOwnsSource(botId, sourceId);
|
||||
if (!sourceOwned) {
|
||||
// Check if source exists at all
|
||||
const source = await getSourceById(sourceId);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get the source to check if it's active
|
||||
const source = await getSourceById(sourceId);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Trigger the fetch with retry logic
|
||||
const result: FetchResult = await fetchContentWithRetry(sourceId, 3, {
|
||||
maxItems: 50,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Get updated source state
|
||||
const updatedSource = await getSourceById(sourceId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: result.success,
|
||||
result: {
|
||||
sourceId: result.sourceId,
|
||||
itemsFetched: result.itemsFetched,
|
||||
itemsStored: result.itemsStored,
|
||||
error: result.error,
|
||||
warnings: result.warnings,
|
||||
},
|
||||
source: updatedSource ? {
|
||||
id: updatedSource.id,
|
||||
botId: updatedSource.botId,
|
||||
type: updatedSource.type,
|
||||
url: updatedSource.url,
|
||||
subreddit: updatedSource.subreddit,
|
||||
keywords: updatedSource.keywords,
|
||||
isActive: updatedSource.isActive,
|
||||
lastFetchAt: updatedSource.lastFetchAt,
|
||||
lastError: updatedSource.lastError,
|
||||
consecutiveErrors: updatedSource.consecutiveErrors,
|
||||
createdAt: updatedSource.createdAt,
|
||||
updatedAt: updatedSource.updatedAt,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Manual fetch error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof ContentSourceNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch content' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Content Source Detail API Routes
|
||||
*
|
||||
* PUT /api/bots/[id]/sources/[sid] - Update content source
|
||||
* DELETE /api/bots/[id]/sources/[sid] - Remove content source
|
||||
*
|
||||
* Requirements: 4.1, 4.6
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import { userOwnsBot, getBotById } from '@/lib/bots/botManager';
|
||||
import {
|
||||
updateSource,
|
||||
removeSource,
|
||||
getSourceById,
|
||||
botOwnsSource,
|
||||
ContentSourceValidationError,
|
||||
ContentSourceNotFoundError,
|
||||
MAX_KEYWORDS,
|
||||
MAX_KEYWORD_LENGTH,
|
||||
} from '@/lib/bots/contentSource';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; sid: string }> };
|
||||
|
||||
// Schema for updating a content source
|
||||
const updateSourceSchema = z.object({
|
||||
url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long').optional(),
|
||||
keywords: z.array(
|
||||
z.string()
|
||||
.min(1, 'Keyword cannot be empty')
|
||||
.max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`)
|
||||
)
|
||||
.max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`)
|
||||
.optional()
|
||||
.nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/bots/[id]/sources/[sid] - Update content source
|
||||
*
|
||||
* Requires authentication.
|
||||
* Updates the content source if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 4.1
|
||||
*/
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: botId, sid: sourceId } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = updateSourceSchema.parse(body);
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, botId);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(botId);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if the source belongs to this bot
|
||||
const sourceOwned = await botOwnsSource(botId, sourceId);
|
||||
if (!sourceOwned) {
|
||||
// Check if source exists at all
|
||||
const source = await getSourceById(sourceId);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updates: Parameters<typeof updateSource>[1] = {};
|
||||
if (data.url !== undefined) updates.url = data.url;
|
||||
if (data.keywords !== undefined) updates.keywords = data.keywords ?? undefined;
|
||||
if (data.isActive !== undefined) updates.isActive = data.isActive;
|
||||
|
||||
// Update the source
|
||||
const updatedSource = await updateSource(sourceId, updates);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
source: {
|
||||
id: updatedSource.id,
|
||||
botId: updatedSource.botId,
|
||||
type: updatedSource.type,
|
||||
url: updatedSource.url,
|
||||
subreddit: updatedSource.subreddit,
|
||||
keywords: updatedSource.keywords,
|
||||
isActive: updatedSource.isActive,
|
||||
lastFetchAt: updatedSource.lastFetchAt,
|
||||
lastError: updatedSource.lastError,
|
||||
consecutiveErrors: updatedSource.consecutiveErrors,
|
||||
createdAt: updatedSource.createdAt,
|
||||
updatedAt: updatedSource.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update content source error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof ContentSourceNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ContentSourceValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code, details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update content source' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/bots/[id]/sources/[sid] - Remove content source
|
||||
*
|
||||
* Requires authentication.
|
||||
* Removes the content source if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 4.1
|
||||
*/
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: botId, sid: sourceId } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, botId);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(botId);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if the source belongs to this bot
|
||||
const sourceOwned = await botOwnsSource(botId, sourceId);
|
||||
if (!sourceOwned) {
|
||||
// Check if source exists at all
|
||||
const source = await getSourceById(sourceId);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Remove the source
|
||||
await removeSource(sourceId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Content source removed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove content source error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof ContentSourceNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove content source' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Content Source API Routes
|
||||
*
|
||||
* POST /api/bots/[id]/sources - Add content source
|
||||
* GET /api/bots/[id]/sources - List content sources
|
||||
*
|
||||
* Requirements: 4.1, 4.6
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import { userOwnsBot, getBotById } from '@/lib/bots/botManager';
|
||||
import {
|
||||
addSource,
|
||||
getSourcesByBot,
|
||||
ContentSourceValidationError,
|
||||
BotNotFoundError,
|
||||
SUPPORTED_SOURCE_TYPES,
|
||||
MAX_KEYWORDS,
|
||||
MAX_KEYWORD_LENGTH,
|
||||
} from '@/lib/bots/contentSource';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Schema for Brave News config
|
||||
const braveNewsConfigSchema = z.object({
|
||||
query: z.string().min(1, 'Search query is required'),
|
||||
freshness: z.enum(['pd', 'pw', 'pm', 'py']).optional(),
|
||||
country: z.string().length(2, 'Country must be a 2-letter ISO code').optional(),
|
||||
searchLang: z.string().optional(),
|
||||
count: z.number().min(1).max(50).optional(),
|
||||
}).optional();
|
||||
|
||||
// Schema for News API config
|
||||
const newsApiConfigSchema = z.object({
|
||||
provider: z.enum(['newsapi', 'gnews', 'newsdata']),
|
||||
query: z.string().min(1, 'Search query is required'),
|
||||
category: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
// Schema for adding a content source
|
||||
const addSourceSchema = z.object({
|
||||
type: z.enum(['rss', 'reddit', 'news_api', 'brave_news', 'youtube'], {
|
||||
message: `Source type must be one of: ${SUPPORTED_SOURCE_TYPES.join(', ')}`,
|
||||
}),
|
||||
url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long'),
|
||||
subreddit: z.string()
|
||||
.regex(/^[a-zA-Z0-9_]{3,21}$/, 'Subreddit name must be 3-21 characters, alphanumeric and underscores only')
|
||||
.optional(),
|
||||
apiKey: z.string().min(10, 'API key is too short').max(256, 'API key is too long').optional(),
|
||||
keywords: z.array(
|
||||
z.string()
|
||||
.min(1, 'Keyword cannot be empty')
|
||||
.max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`)
|
||||
)
|
||||
.max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`)
|
||||
.optional(),
|
||||
braveNewsConfig: braveNewsConfigSchema,
|
||||
newsApiConfig: newsApiConfigSchema,
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Reddit sources require subreddit
|
||||
if (data.type === 'reddit' && !data.subreddit) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'Subreddit name is required for Reddit sources', path: ['subreddit'] }
|
||||
).refine(
|
||||
(data) => {
|
||||
// News API and Brave News sources require apiKey
|
||||
if ((data.type === 'news_api' || data.type === 'brave_news') && !data.apiKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'API key is required for news API sources', path: ['apiKey'] }
|
||||
).refine(
|
||||
(data) => {
|
||||
// Brave News sources require braveNewsConfig with query
|
||||
if (data.type === 'brave_news' && !data.braveNewsConfig?.query) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'Search query is required for Brave News sources', path: ['braveNewsConfig'] }
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/bots/[id]/sources - Add content source
|
||||
*
|
||||
* Requires authentication.
|
||||
* Adds a new content source to the bot if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 4.1, 4.6
|
||||
*/
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: botId } = await context.params;
|
||||
const body = await request.json();
|
||||
const data = addSourceSchema.parse(body);
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, botId);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(botId);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Add the content source
|
||||
const source = await addSource(botId, {
|
||||
type: data.type,
|
||||
url: data.url,
|
||||
subreddit: data.subreddit,
|
||||
apiKey: data.apiKey,
|
||||
keywords: data.keywords,
|
||||
braveNewsConfig: data.braveNewsConfig,
|
||||
newsApiConfig: data.newsApiConfig,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
source: {
|
||||
id: source.id,
|
||||
botId: source.botId,
|
||||
type: source.type,
|
||||
url: source.url,
|
||||
subreddit: source.subreddit,
|
||||
keywords: source.keywords,
|
||||
sourceConfig: source.sourceConfig,
|
||||
isActive: source.isActive,
|
||||
lastFetchAt: source.lastFetchAt,
|
||||
lastError: source.lastError,
|
||||
consecutiveErrors: source.consecutiveErrors,
|
||||
createdAt: source.createdAt,
|
||||
updatedAt: source.updatedAt,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Add content source error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ContentSourceValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code, details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to add content source' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bots/[id]/sources - List content sources
|
||||
*
|
||||
* Requires authentication.
|
||||
* Returns all content sources for the bot if the user owns the bot.
|
||||
*
|
||||
* Validates: Requirements 4.6
|
||||
*/
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: botId } = await context.params;
|
||||
|
||||
// Check if user owns the bot
|
||||
const isOwner = await userOwnsBot(user.id, botId);
|
||||
if (!isOwner) {
|
||||
// Check if bot exists at all
|
||||
const bot = await getBotById(botId);
|
||||
if (!bot) {
|
||||
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get all sources for the bot
|
||||
const sources = await getSourcesByBot(botId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sources: sources.map(source => ({
|
||||
id: source.id,
|
||||
botId: source.botId,
|
||||
type: source.type,
|
||||
url: source.url,
|
||||
subreddit: source.subreddit,
|
||||
keywords: source.keywords,
|
||||
sourceConfig: source.sourceConfig,
|
||||
isActive: source.isActive,
|
||||
lastFetchAt: source.lastFetchAt,
|
||||
lastError: source.lastError,
|
||||
consecutiveErrors: source.consecutiveErrors,
|
||||
createdAt: source.createdAt,
|
||||
updatedAt: source.updatedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List content sources error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list content sources' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Bot Suspension API Route
|
||||
*
|
||||
* POST /api/bots/[id]/suspend - Suspend a bot (admin only)
|
||||
*
|
||||
* Requirements: 10.6
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { db, bots } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { suspendBot } from '@/lib/bots/suspension';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Add admin check here
|
||||
// For now, only bot owner can suspend
|
||||
|
||||
const { id: botId } = await params;
|
||||
const body = await request.json();
|
||||
const { reason } = body;
|
||||
|
||||
if (!reason) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Suspension reason is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify bot exists
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
columns: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Suspend the bot
|
||||
const suspendedBot = await suspendBot(botId, reason);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bot: {
|
||||
id: suspendedBot.id,
|
||||
isSuspended: suspendedBot.isSuspended,
|
||||
suspensionReason: suspendedBot.suspensionReason,
|
||||
suspendedAt: suspendedBot.suspendedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error suspending bot:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to suspend bot' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Bot API Routes
|
||||
*
|
||||
* POST /api/bots - Create a new bot
|
||||
* GET /api/bots - List user's bots
|
||||
*
|
||||
* Requirements: 1.1, 1.3
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createBot,
|
||||
getBotsByUser,
|
||||
BotLimitExceededError,
|
||||
BotHandleTakenError,
|
||||
BotValidationError,
|
||||
} from '@/lib/bots/botManager';
|
||||
|
||||
// Schema for creating a bot
|
||||
const createBotSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
handle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric and underscores only'),
|
||||
bio: z.string().max(500).optional(),
|
||||
avatarUrl: z.string().url().optional(),
|
||||
headerUrl: z.string().url().optional(),
|
||||
personality: z.object({
|
||||
systemPrompt: z.string().min(1).max(10000),
|
||||
temperature: z.number().min(0).max(2),
|
||||
maxTokens: z.number().int().min(1).max(100000),
|
||||
responseStyle: z.string().optional(),
|
||||
}),
|
||||
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']),
|
||||
llmModel: z.string().min(1),
|
||||
llmApiKey: z.string().min(1),
|
||||
schedule: z.object({
|
||||
type: z.enum(['interval', 'times', 'cron']),
|
||||
intervalMinutes: z.number().int().min(5).optional(),
|
||||
times: z.array(z.string().regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/)).optional(),
|
||||
cronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
}).optional(),
|
||||
autonomousMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/bots - Create a new bot
|
||||
*
|
||||
* Requires authentication.
|
||||
* Creates a new bot linked to the authenticated user's account.
|
||||
*
|
||||
* Validates: Requirements 1.1, 1.2
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = createBotSchema.parse(body);
|
||||
|
||||
const bot = await createBot(user.id, {
|
||||
name: data.name,
|
||||
handle: data.handle,
|
||||
bio: data.bio,
|
||||
avatarUrl: data.avatarUrl,
|
||||
headerUrl: data.headerUrl,
|
||||
personality: data.personality,
|
||||
llmProvider: data.llmProvider,
|
||||
llmModel: data.llmModel,
|
||||
llmApiKey: data.llmApiKey,
|
||||
schedule: data.schedule,
|
||||
autonomousMode: data.autonomousMode,
|
||||
});
|
||||
|
||||
// Return bot without sensitive data
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bot: {
|
||||
id: bot.id,
|
||||
userId: bot.userId,
|
||||
name: bot.name,
|
||||
handle: bot.handle,
|
||||
bio: bot.bio,
|
||||
avatarUrl: bot.avatarUrl,
|
||||
personalityConfig: bot.personalityConfig,
|
||||
llmProvider: bot.llmProvider,
|
||||
llmModel: bot.llmModel,
|
||||
scheduleConfig: bot.scheduleConfig,
|
||||
autonomousMode: bot.autonomousMode,
|
||||
isActive: bot.isActive,
|
||||
isSuspended: bot.isSuspended,
|
||||
lastPostAt: bot.lastPostAt,
|
||||
createdAt: bot.createdAt,
|
||||
updatedAt: bot.updatedAt,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Create bot error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (error instanceof BotLimitExceededError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof BotHandleTakenError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof BotValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create bot' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bots - List user's bots
|
||||
*
|
||||
* Requires authentication.
|
||||
* Returns all bots belonging to the authenticated user.
|
||||
*
|
||||
* Validates: Requirements 1.3
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const userBots = await getBotsByUser(user.id);
|
||||
|
||||
// Return bots without sensitive data
|
||||
const sanitizedBots = userBots.map(bot => ({
|
||||
id: bot.id,
|
||||
userId: bot.userId,
|
||||
name: bot.name,
|
||||
handle: bot.handle,
|
||||
bio: bot.bio,
|
||||
avatarUrl: bot.avatarUrl,
|
||||
personalityConfig: bot.personalityConfig,
|
||||
llmProvider: bot.llmProvider,
|
||||
llmModel: bot.llmModel,
|
||||
scheduleConfig: bot.scheduleConfig,
|
||||
autonomousMode: bot.autonomousMode,
|
||||
isActive: bot.isActive,
|
||||
isSuspended: bot.isSuspended,
|
||||
suspensionReason: bot.suspensionReason,
|
||||
lastPostAt: bot.lastPostAt,
|
||||
createdAt: bot.createdAt,
|
||||
updatedAt: bot.updatedAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bots: sanitizedBots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List bots error:', error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list bots' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Cron endpoint for bot scheduled posting
|
||||
*
|
||||
* Call this endpoint periodically (e.g., every minute) via cron job or PM2
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processScheduledPosts } from '@/lib/bots/scheduler';
|
||||
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify using AUTH_SECRET to prevent unauthorized access
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const authSecret = process.env.AUTH_SECRET;
|
||||
|
||||
if (authSecret && authHeader !== `Bearer ${authSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Process scheduled posts
|
||||
const scheduledResult = await processScheduledPosts();
|
||||
|
||||
// Process autonomous bots
|
||||
const autonomousResult = await processAllAutonomousBots();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scheduled: {
|
||||
processed: scheduledResult.processed,
|
||||
skipped: scheduledResult.skipped,
|
||||
errors: scheduledResult.errors.length,
|
||||
},
|
||||
autonomous: {
|
||||
total: autonomousResult.length,
|
||||
posted: autonomousResult.filter(r => r.result.posted).length,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cron bot processing error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process bots', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Cron endpoint for swarm operations
|
||||
*
|
||||
* Called periodically to run gossip rounds and maintain swarm health
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { runGossipRound } from '@/lib/swarm/gossip';
|
||||
import { announceToSeeds } from '@/lib/swarm/discovery';
|
||||
import { getSwarmStats } from '@/lib/swarm/registry';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify using AUTH_SECRET to prevent unauthorized access
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const authSecret = process.env.AUTH_SECRET;
|
||||
|
||||
if (authSecret && authHeader !== `Bearer ${authSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action') || 'gossip';
|
||||
|
||||
try {
|
||||
if (action === 'announce') {
|
||||
// Announce to seed nodes (used on startup)
|
||||
const result = await announceToSeeds();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: 'announce',
|
||||
successful: result.successful,
|
||||
failed: result.failed,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Default: run gossip round
|
||||
const gossipResult = await runGossipRound();
|
||||
const stats = await getSwarmStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: 'gossip',
|
||||
gossip: gossipResult,
|
||||
swarm: stats,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cron swarm processing error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process swarm', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) {
|
||||
// Redirect to default favicon
|
||||
return NextResponse.redirect(new URL('/favicon.png', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'));
|
||||
}
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
columns: { faviconUrl: true },
|
||||
});
|
||||
|
||||
if (node?.faviconUrl) {
|
||||
// Redirect to custom favicon
|
||||
return NextResponse.redirect(node.faviconUrl);
|
||||
}
|
||||
|
||||
// Redirect to default favicon
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || `https://${domain}`;
|
||||
return NextResponse.redirect(new URL('/favicon.png', baseUrl));
|
||||
} catch (error) {
|
||||
console.error('Favicon error:', error);
|
||||
return NextResponse.redirect(new URL('/favicon.png', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const gossipSchema = z.object({
|
||||
nodes: z.array(z.string().min(1)).min(1),
|
||||
since: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const data = gossipSchema.parse(body);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const node of data.nodes) {
|
||||
const baseUrl = node.startsWith('http') ? node : `https://${node}`;
|
||||
const url = new URL('/.well-known/synapsis-handles', baseUrl);
|
||||
if (data.since) {
|
||||
url.searchParams.set('since', data.since);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
results.push({ node, success: false, error: `HTTP ${res.status}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
const handles = Array.isArray(payload?.handles) ? payload.handles : [];
|
||||
const merged = await upsertHandleEntries(handles);
|
||||
|
||||
results.push({
|
||||
node,
|
||||
success: true,
|
||||
added: merged.added,
|
||||
updated: merged.updated,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({ node, success: false, error: 'Fetch failed' });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Gossip error:', error);
|
||||
return NextResponse.json({ error: 'Failed to gossip handles' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const payloadSchema = z.object({
|
||||
handles: z.array(z.object({
|
||||
handle: z.string().min(1),
|
||||
did: z.string().min(1),
|
||||
nodeDomain: z.string().min(1),
|
||||
updatedAt: z.string().optional(),
|
||||
})).min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = payloadSchema.parse(body);
|
||||
|
||||
const result = await upsertHandleEntries(data.handles);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
added: result.added,
|
||||
updated: result.updated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Handle ingest error:', error);
|
||||
return NextResponse.json({ error: 'Failed to ingest handles' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, handleRegistry } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
const parseHandleWithDomain = (handle: string) => {
|
||||
const clean = normalizeHandle(handle);
|
||||
const parts = clean.split('@').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handleParam = searchParams.get('handle');
|
||||
|
||||
if (!handleParam) {
|
||||
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = parseHandleWithDomain(handleParam);
|
||||
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
|
||||
const localEntry = await db.query.handleRegistry.findFirst({
|
||||
where: eq(handleRegistry.handle, lookupHandle),
|
||||
});
|
||||
|
||||
if (localEntry) {
|
||||
return NextResponse.json({
|
||||
handle: localEntry.handle,
|
||||
did: localEntry.did,
|
||||
nodeDomain: localEntry.nodeDomain,
|
||||
updatedAt: localEntry.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL('/.well-known/synapsis-handles', `https://${parsed.domain}`);
|
||||
url.searchParams.set('handle', parsed.handle);
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const entry = Array.isArray(data?.handles) ? data.handles[0] : null;
|
||||
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await upsertHandleEntries([entry]);
|
||||
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
console.error('Handle resolve error:', error);
|
||||
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { count, sql } from 'drizzle-orm';
|
||||
|
||||
const requiredEnv = [
|
||||
'DATABASE_URL',
|
||||
'AUTH_SECRET',
|
||||
'NEXT_PUBLIC_NODE_DOMAIN',
|
||||
'NEXT_PUBLIC_NODE_NAME',
|
||||
'ADMIN_EMAILS',
|
||||
];
|
||||
|
||||
const optionalEnv: string[] = [];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const envStatus = {
|
||||
required: requiredEnv.reduce<Record<string, boolean>>((acc, key) => {
|
||||
acc[key] = Boolean(process.env[key]);
|
||||
return acc;
|
||||
}, {}),
|
||||
optional: optionalEnv.reduce<Record<string, boolean>>((acc, key) => {
|
||||
acc[key] = Boolean(process.env[key]);
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({
|
||||
env: envStatus,
|
||||
db: { connected: false, schemaReady: false, usersCount: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
let schemaReady = true;
|
||||
let usersCount = 0;
|
||||
|
||||
try {
|
||||
await db.execute(sql`select 1 from users limit 1`);
|
||||
const [result] = await db.select({ count: count() }).from(users);
|
||||
usersCount = Number(result?.count || 0);
|
||||
} catch {
|
||||
schemaReady = false;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
env: envStatus,
|
||||
db: { connected: true, schemaReady, usersCount },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Install status error:', error);
|
||||
return NextResponse.json({ error: 'Failed to check status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Check if a URL is from Reddit.
|
||||
*/
|
||||
function isRedditUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.endsWith('reddit.com') || parsed.hostname === 'redd.it';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch preview for Reddit URLs using their oEmbed API.
|
||||
*/
|
||||
async function fetchRedditPreview(url: string): Promise<{
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
|
||||
|
||||
const response = await fetch(oembedUrl, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract title - try title field first, then parse from HTML
|
||||
let title = data.title || null;
|
||||
if (!title && data.html) {
|
||||
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
|
||||
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
|
||||
title = titleMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Build description from subreddit info
|
||||
let description = null;
|
||||
if (data.author_name) {
|
||||
description = `Posted by ${data.author_name}`;
|
||||
} else if (data.html) {
|
||||
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
|
||||
if (subredditMatch) {
|
||||
description = `r/${subredditMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: data.thumbnail_url || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
let url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
// Use Reddit-specific handler
|
||||
if (isRedditUrl(url)) {
|
||||
const preview = await fetchRedditPreview(url);
|
||||
if (preview) {
|
||||
return NextResponse.json(preview);
|
||||
}
|
||||
// Fall back to URL-only response if oEmbed fails
|
||||
return NextResponse.json({
|
||||
url,
|
||||
title: 'Reddit',
|
||||
description: null,
|
||||
image: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Generic OG tag scraping for other sites
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (fetchError) {
|
||||
console.warn(`Fetch failed for URL: ${url}`, fetchError);
|
||||
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
const getMeta = (property: string) => {
|
||||
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
|
||||
const match = html.match(regex);
|
||||
if (match) return match[1];
|
||||
|
||||
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
|
||||
const matchRev = html.match(regexRev);
|
||||
return matchRev ? matchRev[1] : null;
|
||||
};
|
||||
|
||||
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
|
||||
const description = getMeta('description');
|
||||
const image = getMeta('image');
|
||||
|
||||
return NextResponse.json({
|
||||
url,
|
||||
title: title?.trim() || url,
|
||||
description: description?.trim() || null,
|
||||
image: image?.trim() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Link preview error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, media } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
|
||||
const MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 100MB for videos
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const altText = (formData.get('alt') as string | null) || null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const isImage = ALLOWED_IMAGE_TYPES.includes(file.type);
|
||||
const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type);
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file size based on type
|
||||
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json({
|
||||
error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// Sanitize filename to be safe for S3 keys
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// S3 Configuration
|
||||
const s3 = new S3Client({
|
||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
||||
endpoint: process.env.STORAGE_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
||||
},
|
||||
forcePathStyle: true, // Needed for many S3-compatible providers
|
||||
});
|
||||
|
||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read',
|
||||
}));
|
||||
|
||||
// Construct Public URL
|
||||
let url = '';
|
||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
||||
} else if (process.env.STORAGE_ENDPOINT) {
|
||||
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Store media record
|
||||
if (db) {
|
||||
const [mediaRecord] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url,
|
||||
altText,
|
||||
mimeType: file.type,
|
||||
width: 0, // TODO: Get actual dimensions
|
||||
height: 0,
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
media: mediaRecord,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { nodes, users } from '@/db';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!db) return NextResponse.json({});
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
// Fetch admin users based on ADMIN_EMAILS env var
|
||||
const adminEmails = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
.map(e => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
let admins: { handle: string; displayName: string | null; avatarUrl: string | null }[] = [];
|
||||
if (adminEmails.length > 0) {
|
||||
const adminUsers = await db
|
||||
.select({
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
})
|
||||
.from(users)
|
||||
.where(inArray(users.email, adminEmails));
|
||||
admins = adminUsers;
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return NextResponse.json({
|
||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
||||
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#FFFFFF',
|
||||
domain,
|
||||
admins,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...node, admins });
|
||||
} catch (error) {
|
||||
console.error('Node info error:', error);
|
||||
return NextResponse.json({
|
||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
|
||||
admins: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const markSchema = z.object({
|
||||
ids: z.array(z.string().uuid()).optional(),
|
||||
all: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ notifications: [] });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '30'), 50);
|
||||
const unreadOnly = searchParams.get('unread') === 'true';
|
||||
|
||||
const conditions = [eq(notifications.userId, user.id)];
|
||||
if (unreadOnly) {
|
||||
conditions.push(isNull(notifications.readAt));
|
||||
}
|
||||
|
||||
const rows = await db.query.notifications.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
actor: true,
|
||||
post: true,
|
||||
},
|
||||
orderBy: [desc(notifications.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
type ActorInfo = { id: string; handle: string; displayName: string | null; avatarUrl: string | null };
|
||||
type PostInfo = { id: string; content: string };
|
||||
|
||||
const payload = rows.map((row) => {
|
||||
const actor = row.actor as ActorInfo | null;
|
||||
const post = row.post as PostInfo | null;
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
createdAt: row.createdAt,
|
||||
readAt: row.readAt,
|
||||
actor: actor ? {
|
||||
id: actor.id,
|
||||
handle: actor.handle,
|
||||
displayName: actor.displayName,
|
||||
avatarUrl: actor.avatarUrl,
|
||||
} : null,
|
||||
post: post ? {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ notifications: payload });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Notifications fetch error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = markSchema.parse(body);
|
||||
|
||||
if (!data.all && (!data.ids || data.ids.length === 0)) {
|
||||
return NextResponse.json({ error: 'No notifications specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where = data.all
|
||||
? eq(notifications.userId, user.id)
|
||||
: and(
|
||||
eq(notifications.userId, user.id),
|
||||
inArray(notifications.id, data.ids || [])
|
||||
);
|
||||
|
||||
await db.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(where);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Notifications update error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
function extractSwarmDomain(apId: string | null): string | null {
|
||||
if (!apId?.startsWith('swarm:')) return null;
|
||||
const parts = apId.split(':');
|
||||
return parts.length >= 2 ? parts[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is from a swarm node (has swarm: prefix in apId)
|
||||
*/
|
||||
function isSwarmPost(apId: string | null): boolean {
|
||||
return apId?.startsWith('swarm:') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original post ID from a swarm apId
|
||||
*/
|
||||
function extractSwarmPostId(apId: string): string | null {
|
||||
const parts = apId.split(':');
|
||||
return parts.length >= 3 ? parts[2] : null;
|
||||
}
|
||||
|
||||
// Like a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if post exists
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if already liked
|
||||
const existingLike = await db.query.likes.findFirst({
|
||||
where: and(
|
||||
eq(likes.userId, user.id),
|
||||
eq(likes.postId, postId)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
return NextResponse.json({ error: 'Already liked' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create like
|
||||
await db.insert(likes).values({
|
||||
userId: user.id,
|
||||
postId,
|
||||
});
|
||||
|
||||
// Update post's like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: user.id,
|
||||
postId,
|
||||
type: 'like',
|
||||
});
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a swarm post and deliver directly
|
||||
if (isSwarmPost(post.apId)) {
|
||||
const targetDomain = extractSwarmDomain(post.apId);
|
||||
const originalPostId = extractSwarmPostId(post.apId!);
|
||||
|
||||
if (targetDomain && originalPostId) {
|
||||
(async () => {
|
||||
try {
|
||||
const { deliverSwarmLike } = await import('@/lib/swarm/interactions');
|
||||
|
||||
const result = await deliverSwarmLike(targetDomain, {
|
||||
postId: originalPostId,
|
||||
like: {
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName || user.handle,
|
||||
actorAvatarUrl: user.avatarUrl || undefined,
|
||||
actorNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
|
||||
} else {
|
||||
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
|
||||
// Could fall back to ActivityPub here if needed
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering like:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else if (post.apId) {
|
||||
// FALLBACK: Use ActivityPub for non-swarm posts
|
||||
(async () => {
|
||||
try {
|
||||
const { createLikeActivity } = await import('@/lib/activitypub/activities');
|
||||
const { deliverActivity } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
// Get the post author's actor URL
|
||||
const postWithAuthor = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!postWithAuthor?.author) return;
|
||||
|
||||
const author = postWithAuthor.author as { handle: string };
|
||||
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
|
||||
} catch (err) {
|
||||
console.error('[Federation] Error federating like:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, liked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike a post
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if post exists
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find the like
|
||||
const existingLike = await db.query.likes.findFirst({
|
||||
where: and(
|
||||
eq(likes.userId, user.id),
|
||||
eq(likes.postId, postId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingLike) {
|
||||
return NextResponse.json({ error: 'Not liked' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove like
|
||||
await db.delete(likes).where(eq(likes.id, existingLike.id));
|
||||
|
||||
// Update post's like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// SWARM-FIRST: Deliver unlike to swarm node
|
||||
if (isSwarmPost(post.apId)) {
|
||||
const targetDomain = extractSwarmDomain(post.apId);
|
||||
const originalPostId = extractSwarmPostId(post.apId!);
|
||||
|
||||
if (targetDomain && originalPostId) {
|
||||
(async () => {
|
||||
try {
|
||||
const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions');
|
||||
|
||||
const result = await deliverSwarmUnlike(targetDomain, {
|
||||
postId: originalPostId,
|
||||
unlike: {
|
||||
actorHandle: user.handle,
|
||||
actorNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Swarm] Unlike delivered to ${targetDomain}`);
|
||||
} else {
|
||||
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering unlike:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, liked: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
function extractSwarmDomain(apId: string | null): string | null {
|
||||
if (!apId?.startsWith('swarm:')) return null;
|
||||
const parts = apId.split(':');
|
||||
return parts.length >= 2 ? parts[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is from a swarm node
|
||||
*/
|
||||
function isSwarmPost(apId: string | null): boolean {
|
||||
return apId?.startsWith('swarm:') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original post ID from a swarm apId
|
||||
*/
|
||||
function extractSwarmPostId(apId: string): string | null {
|
||||
const parts = apId.split(':');
|
||||
return parts.length >= 3 ? parts[2] : null;
|
||||
}
|
||||
|
||||
// Repost a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if post exists
|
||||
const originalPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
if (!originalPost) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
if (originalPost.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if already reposted by this user
|
||||
const existingRepost = await db.query.posts.findFirst({
|
||||
where: and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.repostOfId, postId),
|
||||
eq(posts.isRemoved, false)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRepost) {
|
||||
return NextResponse.json({ error: 'Already reposted' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create repost
|
||||
const repostId = crypto.randomUUID();
|
||||
const [repost] = await db.insert(posts).values({
|
||||
userId: user.id,
|
||||
content: '', // Reposts don't have their own content
|
||||
repostOfId: postId,
|
||||
apId: `https://${nodeDomain}/posts/${repostId}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${repostId}`,
|
||||
}).returning();
|
||||
|
||||
// Update original post's repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: originalPost.repostsCount + 1 })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (originalPost.userId !== user.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: originalPost.userId,
|
||||
actorId: user.id,
|
||||
postId,
|
||||
type: 'repost',
|
||||
});
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Deliver repost to swarm node
|
||||
if (isSwarmPost(originalPost.apId)) {
|
||||
const targetDomain = extractSwarmDomain(originalPost.apId);
|
||||
const originalPostIdOnRemote = extractSwarmPostId(originalPost.apId!);
|
||||
|
||||
if (targetDomain && originalPostIdOnRemote) {
|
||||
(async () => {
|
||||
try {
|
||||
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
|
||||
|
||||
const result = await deliverSwarmRepost(targetDomain, {
|
||||
postId: originalPostIdOnRemote,
|
||||
repost: {
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName || user.handle,
|
||||
actorAvatarUrl: user.avatarUrl || undefined,
|
||||
actorNodeDomain: nodeDomain,
|
||||
repostId: repost.id,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Swarm] Repost delivered to ${targetDomain}`);
|
||||
} else {
|
||||
console.warn(`[Swarm] Repost delivery failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering repost:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else if (originalPost.apId) {
|
||||
// FALLBACK: Use ActivityPub for non-swarm posts
|
||||
(async () => {
|
||||
try {
|
||||
const { createAnnounceActivity } = await import('@/lib/activitypub/activities');
|
||||
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
// Send Announce to our followers
|
||||
const followerInboxes = await getFollowerInboxes(user.id);
|
||||
if (followerInboxes.length > 0) {
|
||||
const announceActivity = createAnnounceActivity(
|
||||
user,
|
||||
originalPost.apId!,
|
||||
nodeDomain,
|
||||
repost.id
|
||||
);
|
||||
|
||||
const privateKey = user.privateKeyEncrypted;
|
||||
if (privateKey) {
|
||||
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
|
||||
const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId);
|
||||
console.log(`[Federation] Announce for ${originalPost.apId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Federation] Error federating repost:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, repost, reposted: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to repost' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Unrepost a post
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: postId } = await context.params;
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if original post exists
|
||||
const originalPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
});
|
||||
|
||||
// Find the repost by this user
|
||||
const repost = await db.query.posts.findFirst({
|
||||
where: and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.repostOfId, postId),
|
||||
eq(posts.isRemoved, false)
|
||||
),
|
||||
});
|
||||
|
||||
if (!repost) {
|
||||
return NextResponse.json({ error: 'Not reposted' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Mark repost as removed
|
||||
await db.update(posts)
|
||||
.set({ isRemoved: true })
|
||||
.where(eq(posts.id, repost.id));
|
||||
|
||||
// Update original post's repost count
|
||||
if (originalPost) {
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: Math.max(0, originalPost.repostsCount - 1) })
|
||||
.where(eq(posts.id, postId));
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: Math.max(0, user.postsCount - 1) })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({ success: true, reposted: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to unrepost' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remotePosts } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { fetchRemotePost } from '@/lib/activitypub/fetchRemotePost';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
let mainPost: any = null;
|
||||
let replyPosts: any[] = [];
|
||||
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, id),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (post) {
|
||||
mainPost = post;
|
||||
|
||||
const replies = await db.query.posts.findMany({
|
||||
where: and(
|
||||
eq(posts.replyToId, id),
|
||||
eq(posts.isRemoved, false)
|
||||
),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
let allPostIds = [post.id, ...replies.map(r => r.id)];
|
||||
|
||||
try {
|
||||
const { requireAuth } = await import('@/lib/auth');
|
||||
const { likes } = await import('@/db');
|
||||
const { inArray } = await import('drizzle-orm');
|
||||
|
||||
const viewer = await requireAuth();
|
||||
allPostIds = [post.id, ...replies.map(r => r.id)];
|
||||
|
||||
if (allPostIds.length > 0) {
|
||||
const viewerLikes = await db.query.likes.findMany({
|
||||
where: and(
|
||||
eq(likes.userId, viewer.id),
|
||||
inArray(likes.postId, allPostIds)
|
||||
),
|
||||
});
|
||||
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, allPostIds)
|
||||
),
|
||||
});
|
||||
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||
|
||||
mainPost = {
|
||||
...post,
|
||||
isLiked: likedPostIds.has(post.id),
|
||||
isReposted: repostedPostIds.has(post.id),
|
||||
} as any;
|
||||
|
||||
replyPosts = replies.map(r => ({
|
||||
...r,
|
||||
isLiked: likedPostIds.has(r.id),
|
||||
isReposted: repostedPostIds.has(r.id),
|
||||
})) as any[];
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} else {
|
||||
const cached = await db.query.remotePosts.findFirst({
|
||||
where: eq(remotePosts.apId, id),
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
mainPost = {
|
||||
id: cached.id,
|
||||
content: cached.content,
|
||||
createdAt: cached.publishedAt.toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author: {
|
||||
id: cached.authorHandle,
|
||||
handle: cached.authorHandle,
|
||||
displayName: cached.authorDisplayName || cached.authorHandle,
|
||||
avatarUrl: cached.authorAvatarUrl,
|
||||
bio: null,
|
||||
isRemote: true,
|
||||
},
|
||||
media: cached.mediaJson ? JSON.parse(cached.mediaJson) : null,
|
||||
linkPreviewUrl: cached.linkPreviewUrl,
|
||||
linkPreviewTitle: cached.linkPreviewTitle,
|
||||
linkPreviewDescription: cached.linkPreviewDescription,
|
||||
linkPreviewImage: cached.linkPreviewImage,
|
||||
isLiked: false,
|
||||
isReposted: false,
|
||||
};
|
||||
} else {
|
||||
const postUrl = `https://${nodeDomain}/posts/${id}`;
|
||||
const result = await fetchRemotePost(postUrl, nodeDomain);
|
||||
|
||||
if (result.post) {
|
||||
mainPost = result.post;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainPost) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
post: mainPost,
|
||||
replies: replyPosts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get post detail error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get post detail' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { requireAuth } = await import('@/lib/auth');
|
||||
const { bots } = await import('@/db');
|
||||
const user = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, id),
|
||||
with: {
|
||||
bot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Allow deletion if user owns the post OR if user owns the bot that made the post
|
||||
const isPostOwner = post.userId === user.id;
|
||||
const isBotOwner = post.bot && post.bot.ownerId === user.id;
|
||||
|
||||
if (!isPostOwner && !isBotOwner) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 1. If it's a reply, decrement parent's repliesCount
|
||||
if (post.replyToId) {
|
||||
const parentPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, post.replyToId),
|
||||
});
|
||||
if (parentPost && parentPost.repliesCount > 0) {
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: parentPost.repliesCount - 1 })
|
||||
.where(eq(posts.id, post.replyToId));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete the post (cascades to media, likes, notifications)
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
// 3. Decrement the post author's postsCount
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, post.userId),
|
||||
});
|
||||
if (postAuthor && postAuthor.postsCount > 0) {
|
||||
await db.update(users)
|
||||
.set({ postsCount: postAuthor.postsCount - 1 })
|
||||
.where(eq(users.id, post.userId));
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete post error:', error);
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const POST_MAX_LENGTH = 600;
|
||||
const CURATION_WINDOW_HOURS = 72;
|
||||
const CURATION_SEED_MULTIPLIER = 5;
|
||||
const CURATION_SEED_CAP = 200;
|
||||
|
||||
const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
||||
const filtered = conditions.filter(Boolean) as SQL[];
|
||||
if (filtered.length === 0) return undefined;
|
||||
return and(...filtered);
|
||||
};
|
||||
|
||||
const createPostSchema = z.object({
|
||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||
replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
|
||||
swarmReplyTo: z.object({
|
||||
postId: z.string(),
|
||||
nodeDomain: z.string(),
|
||||
}).optional(),
|
||||
mediaIds: z.array(z.string().uuid()).max(4).optional(),
|
||||
isNsfw: z.boolean().optional(),
|
||||
linkPreview: z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image: z.string().url().optional().nullable(),
|
||||
}).optional().nullable(),
|
||||
});
|
||||
|
||||
// Create a new post
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = createPostSchema.parse(body);
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
const [post] = await db.insert(posts).values({
|
||||
userId: user.id,
|
||||
content: data.content,
|
||||
replyToId: data.replyToId,
|
||||
isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW
|
||||
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
|
||||
linkPreviewUrl: data.linkPreview?.url,
|
||||
linkPreviewTitle: data.linkPreview?.title,
|
||||
linkPreviewDescription: data.linkPreview?.description,
|
||||
linkPreviewImage: data.linkPreview?.image,
|
||||
}).returning();
|
||||
|
||||
let attachedMedia: typeof media.$inferSelect[] = [];
|
||||
if (data.mediaIds?.length) {
|
||||
await db.update(media)
|
||||
.set({ postId: post.id })
|
||||
.where(and(
|
||||
inArray(media.id, data.mediaIds),
|
||||
eq(media.userId, user.id),
|
||||
isNull(media.postId),
|
||||
));
|
||||
|
||||
attachedMedia = await db.query.media.findMany({
|
||||
where: and(
|
||||
inArray(media.id, data.mediaIds),
|
||||
eq(media.userId, user.id),
|
||||
eq(media.postId, post.id),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// If this is a reply, update the parent's reply count
|
||||
if (data.replyToId) {
|
||||
const parentPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.replyToId),
|
||||
});
|
||||
if (parentPost) {
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: parentPost.repliesCount + 1 })
|
||||
.where(eq(posts.id, data.replyToId));
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a reply to a swarm post, deliver it to the origin node
|
||||
if (data.swarmReplyTo) {
|
||||
(async () => {
|
||||
try {
|
||||
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
|
||||
|
||||
const replyPayload = {
|
||||
postId: data.swarmReplyTo!.postId,
|
||||
reply: {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: user.handle,
|
||||
displayName: user.displayName || user.handle,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
},
|
||||
nodeDomain,
|
||||
mediaUrls: attachedMedia.map(m => m.url),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(replyPayload),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`);
|
||||
} else {
|
||||
console.error(`[Swarm] Failed to deliver reply: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering reply:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Deliver mentions to swarm nodes
|
||||
(async () => {
|
||||
try {
|
||||
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
|
||||
|
||||
const result = await deliverSwarmMentions(
|
||||
data.content,
|
||||
post.id,
|
||||
{
|
||||
handle: user.handle,
|
||||
displayName: user.displayName || user.handle,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
nodeDomain,
|
||||
}
|
||||
);
|
||||
|
||||
if (result.delivered > 0) {
|
||||
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering mentions:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
// Federate the post to remote followers (non-blocking)
|
||||
(async () => {
|
||||
try {
|
||||
// SWARM-FIRST: Deliver to swarm followers directly
|
||||
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
|
||||
|
||||
const swarmResult = await deliverPostToSwarmFollowers(
|
||||
user.id,
|
||||
post,
|
||||
{
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isNsfw: user.isNsfw,
|
||||
},
|
||||
attachedMedia,
|
||||
nodeDomain
|
||||
);
|
||||
|
||||
if (swarmResult.delivered > 0) {
|
||||
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
||||
}
|
||||
|
||||
// FALLBACK: Deliver to ActivityPub followers
|
||||
const { createCreateActivity } = await import('@/lib/activitypub/activities');
|
||||
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
const followerInboxes = await getFollowerInboxes(user.id);
|
||||
if (followerInboxes.length === 0) {
|
||||
return; // No remote followers to notify
|
||||
}
|
||||
|
||||
const createActivity = createCreateActivity(post, user, nodeDomain);
|
||||
|
||||
const privateKey = user.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
console.error('[Federation] User has no private key for signing');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
|
||||
const result = await deliverToFollowers(createActivity, followerInboxes, privateKey, keyId);
|
||||
console.log(`[Federation] Post ${post.id} delivered to ${result.delivered}/${followerInboxes.length} inboxes (${result.failed} failed)`);
|
||||
} catch (err) {
|
||||
console.error('[Federation] Error federating post:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
|
||||
} catch (error) {
|
||||
console.error('Create post error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
|
||||
const normalizeForDedup = (content: string): string => {
|
||||
return content
|
||||
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
|
||||
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like ‘
|
||||
.replace(/&#\d+;/g, '') // Remove numeric entities
|
||||
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
|
||||
.replace(/[^\w\s]/g, '') // Remove punctuation
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.slice(0, 50); // Compare first 50 chars (article title)
|
||||
};
|
||||
|
||||
// Helper to transform cached remote posts to match local post format
|
||||
// Deduplicates by apId AND by similar content from same author
|
||||
const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]) => {
|
||||
const seenApIds = new Set<string>();
|
||||
const seenContentKeys = new Set<string>(); // author+normalizedContent
|
||||
const uniquePosts: typeof remotePosts.$inferSelect[] = [];
|
||||
|
||||
for (const rp of remotePostsData) {
|
||||
if (seenApIds.has(rp.apId)) continue;
|
||||
|
||||
// Content-based dedup: same author + similar content = skip
|
||||
const contentKey = `${rp.authorHandle}:${normalizeForDedup(rp.content)}`;
|
||||
if (seenContentKeys.has(contentKey)) continue;
|
||||
|
||||
seenApIds.add(rp.apId);
|
||||
seenContentKeys.add(contentKey);
|
||||
uniquePosts.push(rp);
|
||||
}
|
||||
|
||||
return uniquePosts.map(rp => {
|
||||
const mediaData = rp.mediaJson ? JSON.parse(rp.mediaJson) : [];
|
||||
return {
|
||||
id: rp.id,
|
||||
content: rp.content,
|
||||
createdAt: rp.publishedAt,
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
isRemote: true,
|
||||
apId: rp.apId,
|
||||
linkPreviewUrl: rp.linkPreviewUrl,
|
||||
linkPreviewTitle: rp.linkPreviewTitle,
|
||||
linkPreviewDescription: rp.linkPreviewDescription,
|
||||
linkPreviewImage: rp.linkPreviewImage,
|
||||
author: {
|
||||
id: rp.authorActorUrl,
|
||||
handle: rp.authorHandle,
|
||||
displayName: rp.authorDisplayName,
|
||||
avatarUrl: rp.authorAvatarUrl,
|
||||
isRemote: true,
|
||||
},
|
||||
media: mediaData.map((m: { url: string; altText?: string }, idx: number) => ({
|
||||
id: `${rp.id}-media-${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})),
|
||||
replyTo: null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Get timeline / feed
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Return empty posts if no database is connected (for UI testing)
|
||||
if (!db) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'home'; // home, public, user, curated
|
||||
const userId = searchParams.get('userId');
|
||||
const cursor = searchParams.get('cursor');
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
let feedPosts;
|
||||
const baseFilter = buildWhere(
|
||||
eq(posts.isRemoved, false)
|
||||
);
|
||||
|
||||
if (type === 'local') {
|
||||
// Local node posts only - no fediverse content
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
} else if (type === 'public') {
|
||||
// Public timeline - all local posts + all cached remote posts
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit: limit * 2,
|
||||
});
|
||||
|
||||
// Get all cached remote posts
|
||||
const remotePostsData = await db.query.remotePosts.findMany({
|
||||
orderBy: [desc(remotePosts.publishedAt)],
|
||||
limit: limit,
|
||||
});
|
||||
|
||||
const transformedRemote = transformRemotePosts(remotePostsData);
|
||||
|
||||
// Merge and sort by date
|
||||
feedPosts = [...localPosts, ...transformedRemote]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, limit) as any;
|
||||
} else if (type === 'user' && userId) {
|
||||
// User's posts
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: buildWhere(baseFilter, eq(posts.userId, userId)),
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
} else if (type === 'curated') {
|
||||
// Curated feed - swarm posts only (no fediverse)
|
||||
let viewer = null;
|
||||
let includeNsfw = false;
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
viewer = session?.user || null;
|
||||
includeNsfw = session?.user?.nsfwEnabled ?? false;
|
||||
} catch {
|
||||
viewer = null;
|
||||
includeNsfw = false;
|
||||
}
|
||||
|
||||
// Fetch swarm posts with user's NSFW preference
|
||||
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
||||
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
|
||||
|
||||
// Transform swarm posts to match local post format
|
||||
const swarmPosts = swarmResult.posts.map(sp => ({
|
||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||
originalPostId: sp.id, // Keep the original ID for replies
|
||||
content: sp.content,
|
||||
createdAt: new Date(sp.createdAt),
|
||||
likesCount: sp.likeCount,
|
||||
repostsCount: sp.repostCount,
|
||||
repliesCount: sp.replyCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: sp.nodeDomain,
|
||||
author: {
|
||||
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
|
||||
handle: sp.author.handle,
|
||||
displayName: sp.author.displayName,
|
||||
avatarUrl: sp.author.avatarUrl,
|
||||
isSwarm: true,
|
||||
nodeDomain: sp.nodeDomain,
|
||||
},
|
||||
media: sp.media?.map((m, idx) => ({
|
||||
id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
mimeType: m.mimeType || null,
|
||||
})) || [],
|
||||
linkPreviewUrl: sp.linkPreviewUrl || null,
|
||||
linkPreviewTitle: sp.linkPreviewTitle || null,
|
||||
linkPreviewDescription: sp.linkPreviewDescription || null,
|
||||
linkPreviewImage: sp.linkPreviewImage || null,
|
||||
replyTo: null,
|
||||
}));
|
||||
|
||||
let mutedIds = new Set<string>();
|
||||
let blockedIds = new Set<string>();
|
||||
|
||||
if (viewer) {
|
||||
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
|
||||
.from(mutes)
|
||||
.where(eq(mutes.userId, viewer.id));
|
||||
mutedIds = new Set(muteRows.map(row => row.mutedUserId));
|
||||
|
||||
const blockRows = await db.select({ blockedUserId: blocks.blockedUserId })
|
||||
.from(blocks)
|
||||
.where(eq(blocks.userId, viewer.id));
|
||||
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const rankedPosts = swarmPosts
|
||||
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
||||
.map((post: any) => {
|
||||
const createdAt = new Date(post.createdAt).getTime();
|
||||
const ageHours = Math.max(0, (now - createdAt) / 3600000);
|
||||
const engagement = (post.likesCount || 0) + (post.repostsCount || 0) * 2 + (post.repliesCount || 0) * 0.5;
|
||||
const engagementScore = Math.log1p(Math.max(0, engagement));
|
||||
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
|
||||
|
||||
const score = engagementScore * 1.4 + recencyScore * 1.1;
|
||||
|
||||
const reasons: string[] = [];
|
||||
reasons.push(`From ${post.nodeDomain}`);
|
||||
if (engagement >= 5) {
|
||||
reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`);
|
||||
} else if ((post.repliesCount || 0) > 0) {
|
||||
reasons.push(`Active conversation: ${post.repliesCount} replies`);
|
||||
}
|
||||
if (ageHours <= 6) {
|
||||
reasons.push('Posted recently');
|
||||
} else if (ageHours <= 24) {
|
||||
reasons.push('Posted today');
|
||||
}
|
||||
if (reasons.length === 1) {
|
||||
reasons.push('New post');
|
||||
}
|
||||
|
||||
return {
|
||||
...post,
|
||||
feedMeta: {
|
||||
score: Number(score.toFixed(3)),
|
||||
reasons,
|
||||
engagement: {
|
||||
likes: post.likesCount || 0,
|
||||
reposts: post.repostsCount || 0,
|
||||
replies: post.repliesCount || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
if (b.feedMeta.score !== a.feedMeta.score) {
|
||||
return b.feedMeta.score - a.feedMeta.score;
|
||||
}
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
feedPosts = rankedPosts;
|
||||
} else {
|
||||
// Home timeline - need auth
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
// Get IDs of users the current user follows
|
||||
const followRows = await db.select({ followingId: follows.followingId })
|
||||
.from(follows)
|
||||
.where(eq(follows.followerId, user.id));
|
||||
const followingIds = followRows.map(row => row.followingId);
|
||||
|
||||
// Include own posts + posts from followed users
|
||||
const allowedUserIds = [user.id, ...followingIds];
|
||||
|
||||
// Get local posts from people the user follows + their own posts
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)),
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit: limit * 2, // Get more to account for mixing with remote
|
||||
});
|
||||
|
||||
// Get handles of remote users we follow
|
||||
const followedRemoteUsers = await db.query.remoteFollows.findMany({
|
||||
where: eq(remoteFollows.followerId, user.id),
|
||||
});
|
||||
const followedRemoteHandles = followedRemoteUsers.map(f => f.targetHandle);
|
||||
|
||||
// Get cached remote posts from followed users
|
||||
let remotePostsData: typeof remotePosts.$inferSelect[] = [];
|
||||
if (followedRemoteHandles.length > 0) {
|
||||
remotePostsData = await db.query.remotePosts.findMany({
|
||||
where: inArray(remotePosts.authorHandle, followedRemoteHandles),
|
||||
orderBy: [desc(remotePosts.publishedAt)],
|
||||
limit: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Transform remote posts to match local post format (with deduplication)
|
||||
const transformedRemotePosts = transformRemotePosts(remotePostsData);
|
||||
|
||||
// Merge and sort by date
|
||||
const allPosts = [...localPosts, ...transformedRemotePosts]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
feedPosts = allPosts as any;
|
||||
} catch {
|
||||
// Not authenticated, return public timeline
|
||||
feedPosts = await db.query.posts.findMany({
|
||||
where: baseFilter,
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Populate isLiked and isReposted for authenticated users
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
|
||||
if (session?.user && feedPosts && feedPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = feedPosts.map((p: { id: string }) => 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));
|
||||
|
||||
feedPosts = feedPosts.map((p: { id: string }) => ({
|
||||
...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({
|
||||
posts: feedPosts || [],
|
||||
meta: type === 'curated' ? {
|
||||
algorithm: 'curated-v1',
|
||||
windowHours: CURATION_WINDOW_HOURS,
|
||||
seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP),
|
||||
weights: {
|
||||
engagement: 1.4,
|
||||
recency: 1.1,
|
||||
followBoost: 0.9,
|
||||
selfBoost: 0.5,
|
||||
},
|
||||
} : undefined,
|
||||
nextCursor: (feedPosts?.length === limit) ? feedPosts[feedPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get feed error details:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get feed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Swarm Posts Endpoint
|
||||
*
|
||||
* GET: Returns aggregated posts from across the swarm
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* GET /api/posts/swarm
|
||||
*
|
||||
* Returns aggregated posts from across the swarm network.
|
||||
* NSFW content is included based on user's nsfwEnabled setting.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const refresh = searchParams.get('refresh') === 'true';
|
||||
|
||||
// Check user's NSFW preference
|
||||
let includeNsfw = false;
|
||||
try {
|
||||
const session = await getSession();
|
||||
includeNsfw = session?.user?.nsfwEnabled ?? false;
|
||||
} catch {
|
||||
includeNsfw = false;
|
||||
}
|
||||
|
||||
// Fetch swarm timeline (no caching - user preferences vary)
|
||||
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw });
|
||||
|
||||
return NextResponse.json({
|
||||
posts: timeline.posts,
|
||||
sources: timeline.sources,
|
||||
cached: false,
|
||||
fetchedAt: timeline.fetchedAt,
|
||||
// Debug info
|
||||
debug: {
|
||||
includeNsfw,
|
||||
sourceCount: timeline.sources.length,
|
||||
totalPostsBeforeFilter: timeline.sources.reduce((sum, s) => sum + s.postCount, 0),
|
||||
postsAfterFilter: timeline.posts.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm posts error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch swarm posts' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports, posts, users } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const reportSchema = z.object({
|
||||
targetType: z.enum(['post', 'user']),
|
||||
targetId: z.string().uuid(),
|
||||
reason: z.string().min(3).max(500),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const reporter = await requireAuth();
|
||||
|
||||
if (reporter.isSuspended || reporter.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = reportSchema.parse(body);
|
||||
|
||||
if (data.targetType === 'post') {
|
||||
const targetPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.targetId),
|
||||
});
|
||||
if (!targetPost || targetPost.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.targetType === 'user') {
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, data.targetId),
|
||||
});
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const [report] = await db.insert(reports).values({
|
||||
reporterId: reporter.id,
|
||||
targetType: data.targetType,
|
||||
targetId: data.targetId,
|
||||
reason: data.reason,
|
||||
status: 'open',
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json({ report });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Report error:', error);
|
||||
return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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(/</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 => {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Account NSFW Setting API
|
||||
*
|
||||
* POST: Mark/unmark your account as NSFW (content creator setting)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateSchema = z.object({
|
||||
isNsfw: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/account-nsfw
|
||||
*
|
||||
* Mark your account as producing NSFW content.
|
||||
* All your posts will be treated as NSFW.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const { isNsfw } = updateSchema.parse(body);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({
|
||||
isNsfw,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
isNsfw,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { blocks, users } from '@/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
|
||||
// GET - List blocked users
|
||||
export async function GET() {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
|
||||
const blocked = await db.query.blocks.findMany({
|
||||
where: eq(blocks.userId, currentUser.id),
|
||||
with: {
|
||||
blockedUser: true,
|
||||
},
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
blockedUsers: blocked.map(b => ({
|
||||
id: b.blockedUser.id,
|
||||
handle: b.blockedUser.handle,
|
||||
displayName: b.blockedUser.displayName,
|
||||
avatarUrl: b.blockedUser.avatarUrl,
|
||||
blockedAt: b.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Get blocked users error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get blocked users' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Unblock a user by ID
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.delete(blocks).where(
|
||||
and(
|
||||
eq(blocks.userId, currentUser.id),
|
||||
eq(blocks.blockedUserId, userId)
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ unblocked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Unblock user error:', error);
|
||||
return NextResponse.json({ error: 'Failed to unblock user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { mutedNodes } from '@/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
|
||||
// GET - List muted nodes
|
||||
export async function GET() {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
|
||||
const muted = await db.query.mutedNodes.findMany({
|
||||
where: eq(mutedNodes.userId, currentUser.id),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
mutedNodes: muted.map(m => ({
|
||||
domain: m.nodeDomain,
|
||||
mutedAt: m.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Get muted nodes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get muted nodes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Mute a node
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { domain } = await req.json();
|
||||
|
||||
if (!domain || typeof domain !== 'string') {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
|
||||
// Check if already muted
|
||||
const existing = await db.query.mutedNodes.findFirst({
|
||||
where: and(
|
||||
eq(mutedNodes.userId, currentUser.id),
|
||||
eq(mutedNodes.nodeDomain, normalizedDomain)
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ muted: true, domain: normalizedDomain });
|
||||
}
|
||||
|
||||
await db.insert(mutedNodes).values({
|
||||
userId: currentUser.id,
|
||||
nodeDomain: normalizedDomain,
|
||||
});
|
||||
|
||||
return NextResponse.json({ muted: true, domain: normalizedDomain });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Mute node error:', error);
|
||||
return NextResponse.json({ error: 'Failed to mute node' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Unmute a node
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const domain = searchParams.get('domain');
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
|
||||
await db.delete(mutedNodes).where(
|
||||
and(
|
||||
eq(mutedNodes.userId, currentUser.id),
|
||||
eq(mutedNodes.nodeDomain, normalizedDomain)
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ muted: false, domain: normalizedDomain });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Unmute node error:', error);
|
||||
return NextResponse.json({ error: 'Failed to unmute node' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* NSFW Settings API
|
||||
*
|
||||
* GET: Get current user's NSFW settings
|
||||
* POST: Update NSFW settings (requires age verification for enabling)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateSchema = z.object({
|
||||
nsfwEnabled: z.boolean(),
|
||||
confirmAge: z.boolean().optional(), // Must be true when enabling NSFW
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/nsfw
|
||||
*
|
||||
* Returns current user's NSFW settings
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
return NextResponse.json({
|
||||
nsfwEnabled: user.nsfwEnabled,
|
||||
ageVerifiedAt: user.ageVerifiedAt?.toISOString() || null,
|
||||
isNsfw: user.isNsfw, // Whether their account is marked NSFW
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to get settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/settings/nsfw
|
||||
*
|
||||
* Update NSFW settings. Enabling requires age confirmation.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const { nsfwEnabled, confirmAge } = updateSchema.parse(body);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||
}
|
||||
|
||||
// If enabling NSFW and not already verified, require age confirmation
|
||||
if (nsfwEnabled && !user.ageVerifiedAt) {
|
||||
if (!confirmAge) {
|
||||
return NextResponse.json({
|
||||
error: 'Age verification required',
|
||||
requiresAgeConfirmation: true,
|
||||
message: 'You must confirm you are 18 or older to view NSFW content',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Record age verification
|
||||
await db.update(users)
|
||||
.set({
|
||||
nsfwEnabled: true,
|
||||
ageVerifiedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
nsfwEnabled: true,
|
||||
ageVerifiedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update preference (already verified or disabling)
|
||||
await db.update(users)
|
||||
.set({
|
||||
nsfwEnabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
nsfwEnabled,
|
||||
ageVerifiedAt: user.ageVerifiedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Swarm Announce Endpoint
|
||||
*
|
||||
* POST: Receive announcements from other nodes joining the swarm
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { upsertSwarmNode } from '@/lib/swarm/registry';
|
||||
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||
import type { SwarmNodeInfo } from '@/lib/swarm/types';
|
||||
|
||||
const announcementSchema = z.object({
|
||||
domain: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
logoUrl: z.string().url().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
softwareVersion: z.string().optional(),
|
||||
userCount: z.number().optional(),
|
||||
postCount: z.number().optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
signature: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/announce
|
||||
*
|
||||
* Receives an announcement from another node and responds with our info.
|
||||
* This is how nodes introduce themselves to the swarm.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const announcement = announcementSchema.parse(body);
|
||||
|
||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||
|
||||
// Don't process announcements from ourselves
|
||||
if (announcement.domain === ourDomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot announce to self' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Add/update the announcing node in our registry
|
||||
const nodeInfo: SwarmNodeInfo = {
|
||||
domain: announcement.domain,
|
||||
name: announcement.name,
|
||||
description: announcement.description,
|
||||
logoUrl: announcement.logoUrl,
|
||||
publicKey: announcement.publicKey,
|
||||
softwareVersion: announcement.softwareVersion,
|
||||
userCount: announcement.userCount,
|
||||
postCount: announcement.postCount,
|
||||
capabilities: announcement.capabilities,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { isNew } = await upsertSwarmNode(nodeInfo, 'announcement');
|
||||
|
||||
console.log(`[Swarm] ${isNew ? 'New' : 'Known'} node announced: ${announcement.domain}`);
|
||||
|
||||
// Respond with our own info
|
||||
const ourAnnouncement = await buildAnnouncement();
|
||||
|
||||
return NextResponse.json({
|
||||
domain: ourAnnouncement.domain,
|
||||
name: ourAnnouncement.name,
|
||||
description: ourAnnouncement.description,
|
||||
logoUrl: ourAnnouncement.logoUrl,
|
||||
publicKey: ourAnnouncement.publicKey,
|
||||
softwareVersion: ourAnnouncement.softwareVersion,
|
||||
userCount: ourAnnouncement.userCount,
|
||||
postCount: ourAnnouncement.postCount,
|
||||
capabilities: ourAnnouncement.capabilities,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid announcement payload', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('Swarm announce error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process announcement' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Swarm Gossip Endpoint
|
||||
*
|
||||
* POST: Exchange node and handle information with other nodes
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { processGossip } from '@/lib/swarm/gossip';
|
||||
import { markNodeSuccess } from '@/lib/swarm/registry';
|
||||
import type { SwarmGossipPayload } from '@/lib/swarm/types';
|
||||
|
||||
const handleSchema = z.object({
|
||||
handle: z.string(),
|
||||
did: z.string(),
|
||||
nodeDomain: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const nodeInfoSchema = z.object({
|
||||
domain: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
logoUrl: z.string().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
softwareVersion: z.string().optional(),
|
||||
userCount: z.number().optional(),
|
||||
postCount: z.number().optional(),
|
||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
||||
lastSeenAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const gossipPayloadSchema = z.object({
|
||||
sender: z.string().min(1),
|
||||
nodes: z.array(nodeInfoSchema),
|
||||
handles: z.array(handleSchema).optional(),
|
||||
timestamp: z.string(),
|
||||
since: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/gossip
|
||||
*
|
||||
* Receives gossip from another node and responds with our own data.
|
||||
* This is the core of the epidemic protocol - nodes exchange what they know.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const payload = gossipPayloadSchema.parse(body) as SwarmGossipPayload;
|
||||
|
||||
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
|
||||
|
||||
// Don't process gossip from ourselves
|
||||
if (payload.sender === ourDomain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot gossip with self' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Gossip from ${payload.sender}: ${payload.nodes.length} nodes, ${payload.handles?.length || 0} handles`);
|
||||
|
||||
// Process the incoming gossip and build our response
|
||||
const response = await processGossip(payload);
|
||||
|
||||
// Mark the sender as successfully contacted
|
||||
await markNodeSuccess(payload.sender);
|
||||
|
||||
console.log(`[Swarm] Gossip response to ${payload.sender}: ${response.nodes.length} nodes, ${response.handles?.length || 0} handles`);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid gossip payload', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('Swarm gossip error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process gossip' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Swarm Inbox Endpoint
|
||||
*
|
||||
* POST: Receive posts from users on other swarm nodes that local users follow
|
||||
*
|
||||
* This is the swarm equivalent of ActivityPub inbox - when a user on another
|
||||
* Synapsis node creates a post, it gets pushed here for their followers.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmPostSchema = z.object({
|
||||
post: z.object({
|
||||
id: z.string(),
|
||||
content: z.string(),
|
||||
createdAt: z.string(),
|
||||
isNsfw: z.boolean(),
|
||||
replyToId: z.string().optional(),
|
||||
repostOfId: z.string().optional(),
|
||||
media: z.array(z.object({
|
||||
url: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
altText: z.string().optional(),
|
||||
})).optional(),
|
||||
linkPreviewUrl: z.string().optional(),
|
||||
linkPreviewTitle: z.string().optional(),
|
||||
linkPreviewDescription: z.string().optional(),
|
||||
linkPreviewImage: z.string().optional(),
|
||||
}),
|
||||
author: z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().optional(),
|
||||
isNsfw: z.boolean(),
|
||||
}),
|
||||
nodeDomain: z.string(),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/inbox
|
||||
*
|
||||
* Receives a post from another swarm node.
|
||||
* Stores it for local users who follow the author.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Swarm Info Endpoint
|
||||
*
|
||||
* GET: Returns this node's public swarm information
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildAnnouncement } from '@/lib/swarm/discovery';
|
||||
|
||||
/**
|
||||
* GET /api/swarm/info
|
||||
*
|
||||
* Returns this node's public information for the swarm.
|
||||
* Used by other nodes during discovery.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const announcement = await buildAnnouncement();
|
||||
|
||||
return NextResponse.json({
|
||||
domain: announcement.domain,
|
||||
name: announcement.name,
|
||||
description: announcement.description,
|
||||
logoUrl: announcement.logoUrl,
|
||||
publicKey: announcement.publicKey,
|
||||
softwareVersion: announcement.softwareVersion,
|
||||
userCount: announcement.userCount,
|
||||
postCount: announcement.postCount,
|
||||
capabilities: announcement.capabilities,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm info error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get node info' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Swarm Follow Endpoint
|
||||
*
|
||||
* POST: Receive a follow from another swarm node
|
||||
*
|
||||
* This enables swarm-native follows between Synapsis nodes,
|
||||
* bypassing ActivityPub for faster, more direct connections.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmFollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
follow: z.object({
|
||||
followerHandle: z.string(),
|
||||
followerDisplayName: z.string(),
|
||||
followerAvatarUrl: z.string().optional(),
|
||||
followerBio: z.string().optional(),
|
||||
followerNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/follow
|
||||
*
|
||||
* Receives a follow from another swarm node.
|
||||
*/
|
||||
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 = swarmFollowSchema.parse(body);
|
||||
|
||||
// Find the target user (local user being followed)
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.targetHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Construct the remote follower's actor URL (swarm-style)
|
||||
const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`;
|
||||
const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
|
||||
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
|
||||
|
||||
// Check if this follow already exists
|
||||
const existingFollow = await db.query.remoteFollowers.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollowers.userId, targetUser.id),
|
||||
eq(remoteFollowers.actorUrl, actorUrl)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingFollow) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Already following',
|
||||
});
|
||||
}
|
||||
|
||||
// Create the remote follower record
|
||||
await db.insert(remoteFollowers).values({
|
||||
userId: targetUser.id,
|
||||
actorUrl,
|
||||
inboxUrl,
|
||||
handle: remoteHandle,
|
||||
activityId: data.follow.interactionId,
|
||||
});
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Get or create placeholder user for the remote follower (for notifications)
|
||||
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.follow.followerNodeDomain}:${data.follow.followerHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.follow.followerDisplayName,
|
||||
avatarUrl: data.follow.followerAvatarUrl || null,
|
||||
bio: data.follow.followerBio || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: remoteUser.id,
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Follow received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Follow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process follow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Swarm Like Endpoint
|
||||
*
|
||||
* POST: Receive a like from another swarm node
|
||||
*
|
||||
* This is the swarm-first approach - direct node-to-node communication
|
||||
* for likes, bypassing ActivityPub for Synapsis nodes.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmLikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
like: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/like
|
||||
*
|
||||
* Receives a like from another swarm node.
|
||||
*/
|
||||
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 = swarmLikeSchema.parse(body);
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Increment like count
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Create a notification for the post author
|
||||
// First, get or create a placeholder user for the remote liker
|
||||
const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
// Create a placeholder user for the remote actor
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.like.actorDisplayName,
|
||||
avatarUrl: data.like.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: remoteUser.id,
|
||||
postId: data.postId,
|
||||
type: 'like',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Like received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Like error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process like' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Swarm Mention Endpoint
|
||||
*
|
||||
* POST: Receive a mention notification from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmMentionSchema = z.object({
|
||||
mentionedHandle: z.string(),
|
||||
mention: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
postId: z.string(),
|
||||
postContent: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/mention
|
||||
*
|
||||
* Receives a mention notification from another swarm node.
|
||||
*/
|
||||
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 = swarmMentionSchema.parse(body);
|
||||
|
||||
// Find the mentioned user (local user)
|
||||
const mentionedUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.mentionedHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!mentionedUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (mentionedUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get or create placeholder user for the remote actor
|
||||
const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
|
||||
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.mention.actorNodeDomain}:${data.mention.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.mention.actorDisplayName,
|
||||
avatarUrl: data.mention.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Check if we already have this post cached (from swarm timeline)
|
||||
// If not, create a placeholder post for the notification
|
||||
const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`;
|
||||
let post = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, swarmPostId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
// Create a placeholder post for the mention
|
||||
const [newPost] = await db.insert(posts).values({
|
||||
userId: remoteUser.id,
|
||||
content: data.mention.postContent,
|
||||
apId: swarmPostId,
|
||||
apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`,
|
||||
createdAt: new Date(data.mention.timestamp),
|
||||
}).returning();
|
||||
post = newPost;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorId: remoteUser.id,
|
||||
postId: post.id,
|
||||
type: 'mention',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Mention received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Mention error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process mention' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Swarm Repost Endpoint
|
||||
*
|
||||
* POST: Receive a repost from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmRepostSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
repost: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
repostId: z.string(), // The ID of the repost on the actor's node
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/repost
|
||||
*
|
||||
* Receives a repost notification from another swarm node.
|
||||
*/
|
||||
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 = swarmRepostSchema.parse(body);
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Increment repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: post.repostsCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Get or create placeholder user for the remote reposter
|
||||
const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`;
|
||||
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.repost.actorNodeDomain}:${data.repost.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.repost.actorDisplayName,
|
||||
avatarUrl: data.repost.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: remoteUser.id,
|
||||
postId: data.postId,
|
||||
type: 'repost',
|
||||
});
|
||||
|
||||
console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Repost received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Repost error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process repost' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Swarm Unfollow Endpoint
|
||||
*
|
||||
* POST: Receive an unfollow from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmUnfollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
unfollow: z.object({
|
||||
followerHandle: z.string(),
|
||||
followerNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/unfollow
|
||||
*
|
||||
* Receives an unfollow from another swarm node.
|
||||
*/
|
||||
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 = swarmUnfollowSchema.parse(body);
|
||||
|
||||
// Find the target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.targetHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find and remove the remote follower record
|
||||
const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`;
|
||||
|
||||
const existingFollow = await db.query.remoteFollowers.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollowers.userId, targetUser.id),
|
||||
eq(remoteFollowers.actorUrl, actorUrl)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingFollow) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Not following',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the follow
|
||||
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollow.id));
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Unfollow received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Unfollow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process unfollow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Swarm Unlike Endpoint
|
||||
*
|
||||
* POST: Receive an unlike from another swarm node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const swarmUnlikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
unlike: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/unlike
|
||||
*
|
||||
* Receives an unlike from another swarm node.
|
||||
*/
|
||||
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 = swarmUnlikeSchema.parse(body);
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Decrement like count (don't go below 0)
|
||||
await db.update(posts)
|
||||
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
console.log(`[Swarm] Received unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Unlike received',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Unlike error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process unlike' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Swarm Nodes Endpoint
|
||||
*
|
||||
* GET: List all known nodes in the swarm
|
||||
* POST: Trigger discovery/sync operations (admin only)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import {
|
||||
getActiveSwarmNodes,
|
||||
getSwarmStats,
|
||||
addSeedNode,
|
||||
} from '@/lib/swarm/registry';
|
||||
import {
|
||||
announceToSeeds,
|
||||
announceToNode,
|
||||
discoverNode,
|
||||
} from '@/lib/swarm/discovery';
|
||||
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
|
||||
|
||||
/**
|
||||
* GET /api/swarm/nodes
|
||||
*
|
||||
* Returns list of known swarm nodes and stats.
|
||||
* Public endpoint - anyone can see the swarm.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
|
||||
const includeStats = searchParams.get('stats') === 'true';
|
||||
|
||||
const nodes = await getActiveSwarmNodes(limit);
|
||||
|
||||
const response: {
|
||||
nodes: typeof nodes;
|
||||
stats?: Awaited<ReturnType<typeof getSwarmStats>>;
|
||||
} = { nodes };
|
||||
|
||||
if (includeStats) {
|
||||
response.stats = await getSwarmStats();
|
||||
}
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Swarm nodes error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch swarm nodes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(['announce', 'discover', 'gossip', 'addSeed']),
|
||||
domain: z.string().optional(),
|
||||
priority: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/swarm/nodes
|
||||
*
|
||||
* Admin-only endpoint to trigger swarm operations:
|
||||
* - announce: Announce to seeds or a specific node
|
||||
* - discover: Discover a specific node
|
||||
* - gossip: Run a gossip round or gossip with specific node
|
||||
* - addSeed: Add a new seed node
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const { action, domain, priority } = actionSchema.parse(body);
|
||||
|
||||
switch (action) {
|
||||
case 'announce': {
|
||||
if (domain) {
|
||||
const result = await announceToNode(domain);
|
||||
return NextResponse.json({ action, domain, ...result });
|
||||
} else {
|
||||
const result = await announceToSeeds();
|
||||
return NextResponse.json({ action, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
case 'discover': {
|
||||
if (!domain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Domain required for discover action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = await discoverNode(domain);
|
||||
return NextResponse.json({ action, domain, ...result });
|
||||
}
|
||||
|
||||
case 'gossip': {
|
||||
if (domain) {
|
||||
const result = await gossipToNode(domain);
|
||||
return NextResponse.json({ action, domain, ...result });
|
||||
} else {
|
||||
const result = await runGossipRound();
|
||||
return NextResponse.json({ action, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
case 'addSeed': {
|
||||
if (!domain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Domain required for addSeed action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await addSeedNode(domain, priority);
|
||||
return NextResponse.json({ action, domain, success: true });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid payload', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
console.error('Swarm nodes POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to execute swarm action' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Swarm Post Endpoint
|
||||
*
|
||||
* GET: Returns a single post for swarm requests
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/swarm/posts/[id]
|
||||
*
|
||||
* Returns a single post with author info.
|
||||
* Used by other nodes to fetch post details.
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { id: postId } = await context.params;
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Find the post with author
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.isRemoved) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get media for the post
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, postId));
|
||||
|
||||
const author = post.author as {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
isNsfw: boolean;
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: author.handle,
|
||||
displayName: author.displayName || author.handle,
|
||||
avatarUrl: author.avatarUrl || undefined,
|
||||
isNsfw: author.isNsfw,
|
||||
},
|
||||
nodeDomain,
|
||||
isNsfw: post.isNsfw || author.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
replyToId: post.replyToId || undefined,
|
||||
repostOfId: post.repostOfId || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm post error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Swarm Replies Endpoint
|
||||
*
|
||||
* POST: Receive a reply from another node
|
||||
* GET: Fetch replies to a post on this node
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for incoming swarm reply
|
||||
const swarmReplySchema = z.object({
|
||||
postId: z.string().uuid(), // The local post being replied to
|
||||
reply: z.object({
|
||||
id: z.string(), // Original reply ID on the sender's node
|
||||
content: z.string(),
|
||||
createdAt: z.string(),
|
||||
author: z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().optional(),
|
||||
}),
|
||||
nodeDomain: z.string(),
|
||||
mediaUrls: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/swarm/replies?postId=xxx
|
||||
*
|
||||
* Returns replies to a specific post on this node.
|
||||
* Used by other nodes to fetch reply threads.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ replies: [] });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const postId = searchParams.get('postId');
|
||||
|
||||
if (!postId) {
|
||||
return NextResponse.json({ error: 'postId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Get replies to this post
|
||||
const replies = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
authorHandle: users.handle,
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(posts.replyToId, postId),
|
||||
eq(posts.isRemoved, false)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(50);
|
||||
|
||||
// Format replies for swarm consumption
|
||||
const formattedReplies = replies.map(reply => ({
|
||||
id: reply.id,
|
||||
content: reply.content,
|
||||
createdAt: reply.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: reply.authorHandle.includes('@')
|
||||
? reply.authorHandle.split('@')[0]
|
||||
: reply.authorHandle,
|
||||
displayName: reply.authorDisplayName || reply.authorHandle,
|
||||
avatarUrl: reply.authorAvatarUrl || undefined,
|
||||
},
|
||||
nodeDomain: reply.authorHandle.includes('@')
|
||||
? reply.authorHandle.split('@')[1]
|
||||
: nodeDomain,
|
||||
likeCount: reply.likesCount,
|
||||
repostCount: reply.repostsCount,
|
||||
replyCount: reply.repliesCount,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
postId,
|
||||
replies: formattedReplies,
|
||||
nodeDomain,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Swarm] Fetch replies error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch replies' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Swarm Timeline Endpoint
|
||||
*
|
||||
* GET: Returns recent public posts from this node for the swarm timeline
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and, isNull } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
author: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
isNsfw: boolean;
|
||||
};
|
||||
nodeDomain: string;
|
||||
nodeIsNsfw: boolean;
|
||||
isNsfw: boolean;
|
||||
likeCount: number;
|
||||
repostCount: number;
|
||||
replyCount: number;
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
// Link preview
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/swarm/timeline
|
||||
*
|
||||
* Returns recent public posts from this node.
|
||||
* Used by other nodes to build the swarm-wide timeline.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Get node NSFW status
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, nodeDomain),
|
||||
});
|
||||
const nodeIsNsfw = node?.isNsfw ?? false;
|
||||
|
||||
// Get recent public posts (not replies, local users only, not removed)
|
||||
const recentPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
authorHandle: users.handle,
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
authorIsNsfw: users.isNsfw,
|
||||
authorNodeId: users.nodeId,
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(posts.replyToId), // Not a reply
|
||||
eq(posts.isRemoved, false) // Not removed
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmPost[] = [];
|
||||
|
||||
for (const post of recentPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
author: {
|
||||
handle: post.authorHandle,
|
||||
displayName: post.authorDisplayName || post.authorHandle,
|
||||
avatarUrl: post.authorAvatarUrl || undefined,
|
||||
isNsfw: post.authorIsNsfw,
|
||||
},
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level)
|
||||
likeCount: post.likesCount,
|
||||
repostCount: post.repostsCount,
|
||||
replyCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts: swarmPosts,
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm timeline error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch timeline' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Swarm User Profile Endpoint
|
||||
*
|
||||
* GET: Returns a user's profile and posts for swarm requests
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmUserProfile {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
headerUrl?: string;
|
||||
website?: string;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
postsCount: number;
|
||||
createdAt: string;
|
||||
isBot?: boolean;
|
||||
nodeDomain: string;
|
||||
}
|
||||
|
||||
export interface SwarmUserPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
isNsfw: boolean;
|
||||
likesCount: number;
|
||||
repostsCount: number;
|
||||
repliesCount: number;
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
}
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/swarm/users/[handle]
|
||||
*
|
||||
* Returns a user's profile and recent posts.
|
||||
* Used by other nodes to display remote user profiles.
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Build profile response
|
||||
const profile: SwarmUserProfile = {
|
||||
handle: user.handle,
|
||||
displayName: user.displayName || user.handle,
|
||||
bio: user.bio || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
headerUrl: user.headerUrl || undefined,
|
||||
website: user.website || undefined,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
postsCount: user.postsCount,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
isBot: user.isBot || undefined,
|
||||
nodeDomain,
|
||||
};
|
||||
|
||||
// Get user's recent posts
|
||||
const userPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
})
|
||||
.from(posts)
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmUserPost[] = [];
|
||||
|
||||
for (const post of userPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isNsfw: post.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
profile,
|
||||
posts: swarmPosts,
|
||||
nodeDomain,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm user profile error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch user profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// S3 Configuration
|
||||
const s3 = new S3Client({
|
||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
||||
endpoint: process.env.STORAGE_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
||||
},
|
||||
forcePathStyle: true, // Needed for many S3-compatible providers (MinIO, etc.)
|
||||
});
|
||||
|
||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read', // Depends on bucket policy, but often needed
|
||||
}));
|
||||
|
||||
// Construct Public URL
|
||||
let url = '';
|
||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
||||
} else if (process.env.STORAGE_ENDPOINT) {
|
||||
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users, blocks, follows } from '@/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
// GET - Check if blocked
|
||||
export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const block = await db.query.blocks.findFirst({
|
||||
where: and(
|
||||
eq(blocks.userId, currentUser.id),
|
||||
eq(blocks.blockedUserId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
return NextResponse.json({ blocked: !!block });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Check block error:', error);
|
||||
return NextResponse.json({ error: 'Failed to check block status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Block user
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.id === currentUser.id) {
|
||||
return NextResponse.json({ error: 'Cannot block yourself' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
const existing = await db.query.blocks.findFirst({
|
||||
where: and(
|
||||
eq(blocks.userId, currentUser.id),
|
||||
eq(blocks.blockedUserId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ blocked: true });
|
||||
}
|
||||
|
||||
// Create block
|
||||
await db.insert(blocks).values({
|
||||
userId: currentUser.id,
|
||||
blockedUserId: targetUser.id,
|
||||
});
|
||||
|
||||
// Remove any follows between the users
|
||||
await db.delete(follows).where(
|
||||
and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
)
|
||||
);
|
||||
await db.delete(follows).where(
|
||||
and(
|
||||
eq(follows.followerId, targetUser.id),
|
||||
eq(follows.followingId, currentUser.id)
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ blocked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Block user error:', error);
|
||||
return NextResponse.json({ error: 'Failed to block user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Unblock user
|
||||
export async function DELETE(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.delete(blocks).where(
|
||||
and(
|
||||
eq(blocks.userId, currentUser.id),
|
||||
eq(blocks.blockedUserId, targetUser.id)
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ blocked: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Unblock user error:', error);
|
||||
return NextResponse.json({ error: 'Failed to unblock user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
|
||||
import { deliverActivity } from '@/lib/activitypub/outbox';
|
||||
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow } from '@/lib/swarm/interactions';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
|
||||
const stripHtml = (html: string | null | undefined): string | null => {
|
||||
if (!html) return null;
|
||||
return html.replace(/<[^>]*>/g, '').trim() || 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);
|
||||
|
||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
const targetHandle = `${remote.handle}@${remote.domain}`;
|
||||
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollows.followerId, currentUser.id),
|
||||
eq(remoteFollows.targetHandle, targetHandle)
|
||||
),
|
||||
});
|
||||
return NextResponse.json({ following: !!existingRemoteFollow, remote: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.id === currentUser.id) {
|
||||
return NextResponse.json({ following: false, self: true });
|
||||
}
|
||||
|
||||
const existingFollow = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
return NextResponse.json({ following: !!existingFollow });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Follow status error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get follow status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Follow a user
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (currentUser.isSuspended || currentUser.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
const targetHandle = `${remote.handle}@${remote.domain}`;
|
||||
|
||||
// Check if already following
|
||||
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollows.followerId, currentUser.id),
|
||||
eq(remoteFollows.targetHandle, targetHandle)
|
||||
),
|
||||
});
|
||||
if (existingRemoteFollow) {
|
||||
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a Synapsis swarm node
|
||||
const isSwarm = await isSwarmNode(remote.domain);
|
||||
|
||||
if (isSwarm) {
|
||||
// Use swarm protocol for Synapsis nodes
|
||||
const activityId = crypto.randomUUID();
|
||||
|
||||
const result = await deliverSwarmFollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
follow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerDisplayName: currentUser.displayName || currentUser.handle,
|
||||
followerAvatarUrl: currentUser.avatarUrl || undefined,
|
||||
followerBio: currentUser.bio || undefined,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: activityId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
|
||||
// Fall through to ActivityPub below
|
||||
} else {
|
||||
// Swarm follow succeeded - store the follow locally
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: currentUser.id,
|
||||
targetHandle,
|
||||
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
|
||||
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
|
||||
activityId,
|
||||
displayName: null, // Will be fetched later
|
||||
bio: null,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
||||
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
|
||||
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 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 });
|
||||
}
|
||||
|
||||
// Extract avatar URL from remote profile
|
||||
let avatarUrl: string | null = null;
|
||||
if (remoteProfile.icon) {
|
||||
if (typeof remoteProfile.icon === 'string') {
|
||||
avatarUrl = remoteProfile.icon;
|
||||
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
|
||||
avatarUrl = remoteProfile.icon.url;
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: currentUser.id,
|
||||
targetHandle,
|
||||
targetActorUrl: remoteProfile.id,
|
||||
inboxUrl: targetInbox,
|
||||
activityId,
|
||||
displayName: remoteProfile.name || null,
|
||||
bio: stripHtml(remoteProfile.summary),
|
||||
avatarUrl,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
const origin = new URL(request.url).origin;
|
||||
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
|
||||
.then(result => console.log(`Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('Error caching remote posts:', err));
|
||||
|
||||
return NextResponse.json({ success: true, following: true, remote: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Find target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Can't follow yourself
|
||||
if (targetUser.id === currentUser.id) {
|
||||
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already following
|
||||
const existingFollow = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingFollow) {
|
||||
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create follow
|
||||
await db.insert(follows).values({
|
||||
followerId: currentUser.id,
|
||||
followingId: targetUser.id,
|
||||
});
|
||||
|
||||
if (currentUser.id !== targetUser.id) {
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: currentUser.id,
|
||||
type: 'follow',
|
||||
});
|
||||
}
|
||||
|
||||
// Update counts
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Follow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to follow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Unfollow a user
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (remote) {
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
const targetHandle = `${remote.handle}@${remote.domain}`;
|
||||
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
||||
where: and(
|
||||
eq(remoteFollows.followerId, currentUser.id),
|
||||
eq(remoteFollows.targetHandle, targetHandle)
|
||||
),
|
||||
});
|
||||
if (!existingRemoteFollow) {
|
||||
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
|
||||
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
|
||||
|
||||
if (isSwarmFollow) {
|
||||
// Use swarm protocol for unfollow
|
||||
const result = await deliverSwarmUnfollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
unfollow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
|
||||
// Continue anyway - remove local record
|
||||
}
|
||||
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
// FALLBACK: Use ActivityPub for non-swarm follows
|
||||
const originalFollow = createFollowActivity(
|
||||
currentUser,
|
||||
existingRemoteFollow.targetActorUrl,
|
||||
nodeDomain,
|
||||
existingRemoteFollow.activityId
|
||||
);
|
||||
const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID());
|
||||
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(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId);
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
|
||||
}
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: false, remote: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Find target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (targetUser.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find existing follow
|
||||
const existingFollow = await db.query.follows.findFirst({
|
||||
where: and(
|
||||
eq(follows.followerId, currentUser.id),
|
||||
eq(follows.followingId, targetUser.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingFollow) {
|
||||
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove follow
|
||||
await db.delete(follows).where(eq(follows.id, existingFollow.id));
|
||||
|
||||
// Update counts
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Undo Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Unfollow error:', error);
|
||||
return NextResponse.json({ error: 'Failed to unfollow' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, follows, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// Return empty if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({ followers: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get followers
|
||||
const userFollowers = await db
|
||||
.select({
|
||||
id: follows.id,
|
||||
follower: users,
|
||||
})
|
||||
.from(follows)
|
||||
.innerJoin(users, eq(follows.followerId, users.id))
|
||||
.where(eq(follows.followingId, user.id))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({
|
||||
followers: userFollowers.map(f => ({
|
||||
id: f.follower.id,
|
||||
handle: f.follower.handle,
|
||||
displayName: f.follower.displayName,
|
||||
avatarUrl: f.follower.avatarUrl,
|
||||
bio: f.follower.bio,
|
||||
isBot: f.follower.isBot,
|
||||
})),
|
||||
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get followers error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get followers' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, follows, users, remoteFollows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
// Return empty if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({ following: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get local following
|
||||
const userFollowing = await db
|
||||
.select({
|
||||
id: follows.id,
|
||||
following: users,
|
||||
})
|
||||
.from(follows)
|
||||
.innerJoin(users, eq(follows.followingId, users.id))
|
||||
.where(eq(follows.followerId, user.id))
|
||||
.limit(limit);
|
||||
|
||||
const localFollowing = userFollowing.map(f => ({
|
||||
id: f.following.id,
|
||||
handle: f.following.handle,
|
||||
displayName: f.following.displayName,
|
||||
avatarUrl: f.following.avatarUrl,
|
||||
bio: f.following.bio,
|
||||
isRemote: false,
|
||||
isBot: f.following.isBot,
|
||||
}));
|
||||
|
||||
// Get remote following
|
||||
const userRemoteFollowing = await db.query.remoteFollows.findMany({
|
||||
where: eq(remoteFollows.followerId, user.id),
|
||||
limit,
|
||||
});
|
||||
|
||||
const remoteFollowing = userRemoteFollowing.map(f => ({
|
||||
id: f.targetActorUrl,
|
||||
handle: f.targetHandle,
|
||||
displayName: f.displayName || f.targetHandle.split('@')[0], // Use stored display name or username part
|
||||
avatarUrl: f.avatarUrl,
|
||||
bio: f.bio,
|
||||
isRemote: true,
|
||||
}));
|
||||
|
||||
// Merge and return
|
||||
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
|
||||
|
||||
return NextResponse.json({
|
||||
following: allFollowing,
|
||||
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get following error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get following' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ActivityPub User Inbox Endpoint
|
||||
*
|
||||
* Receives incoming activities from remote servers for a specific user.
|
||||
* POST /users/{handle}/inbox
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
|
||||
// Verify the target user exists
|
||||
if (!db) {
|
||||
console.error('[Inbox] Database not available');
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
|
||||
}
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
console.error(`[Inbox] User not found: ${cleanHandle}`);
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the activity
|
||||
let activity: IncomingActivity;
|
||||
try {
|
||||
activity = await request.json();
|
||||
} catch (e) {
|
||||
console.error('[Inbox] Invalid JSON body:', e);
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[Inbox] Received ${activity.type} activity for @${cleanHandle} from ${activity.actor}`);
|
||||
|
||||
// Extract headers for signature verification
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
// Get the request path
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Process the activity
|
||||
const result = await processIncomingActivity(activity, headers, path, targetUser);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[Inbox] Activity processing failed: ${result.error}`);
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Return 202 Accepted (standard for ActivityPub)
|
||||
return new NextResponse(null, { status: 202 });
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error processing activity:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityPub requires the inbox to be discoverable
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const { handle } = await context.params;
|
||||
return NextResponse.json(
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
summary: `Inbox for @${handle}`,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, likes, posts, users } from '@/db';
|
||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Don't show likes for bot accounts
|
||||
if (user.isBot) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
|
||||
// Get user's liked posts
|
||||
const userLikes = await db.query.likes.findMany({
|
||||
where: eq(likes.userId, user.id),
|
||||
with: {
|
||||
post: {
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(likes.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
// Filter out any likes where the post was removed and format response
|
||||
let likedPosts = userLikes
|
||||
.filter(like => like.post && !like.post.isRemoved)
|
||||
.map(like => like.post);
|
||||
|
||||
// Populate isLiked and isReposted for authenticated users
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
|
||||
if (session?.user && likedPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = likedPosts.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));
|
||||
|
||||
likedPosts = likedPosts.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({
|
||||
posts: likedPosts,
|
||||
nextCursor: likedPosts.length === limit ? likedPosts[likedPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user likes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, likes } from '@/db';
|
||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const extractTextAndUrls = (value?: string | null) => {
|
||||
if (!value) return { text: '', urls: [] as string[] };
|
||||
let html = value;
|
||||
// Replace <br> with spaces to avoid words running together.
|
||||
html = html.replace(/<br\s*\/?>/gi, ' ');
|
||||
// Replace anchor tags with their hrefs (preferred) or inner text.
|
||||
html = html.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, text) => {
|
||||
const cleanedHref = decodeEntities(String(href));
|
||||
const cleanedText = decodeEntities(String(text)).replace(/<[^>]*>/g, ' ').trim();
|
||||
return cleanedHref || cleanedText;
|
||||
});
|
||||
const withoutTags = html.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
const text = decoded.replace(/\s+/g, ' ').trim();
|
||||
const urls = Array.from(text.matchAll(/https?:\/\/[^\s]+/gi)).map((match) => match[0]);
|
||||
return { text, urls };
|
||||
};
|
||||
|
||||
const normalizeUrl = (value: string) => value.replace(/[)\].,!?]+$/, '');
|
||||
|
||||
const fetchLinkPreview = async (url: string, origin: string) => {
|
||||
try {
|
||||
const previewUrl = new URL('/api/media/preview', origin);
|
||||
previewUrl.searchParams.set('url', url);
|
||||
const res = await fetch(previewUrl.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
url: data?.url || url,
|
||||
title: data?.title || null,
|
||||
description: data?.description || null,
|
||||
image: data?.image || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const stripFirstUrl = (text: string, url: string) => {
|
||||
const idx = text.indexOf(url);
|
||||
if (idx === -1) return text;
|
||||
const before = text.slice(0, idx).trimEnd();
|
||||
const after = text.slice(idx + url.length).trimStart();
|
||||
return `${before} ${after}`.trim();
|
||||
};
|
||||
|
||||
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
|
||||
const normalizeForDedup = (content: string): string => {
|
||||
return content
|
||||
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
|
||||
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like ‘
|
||||
.replace(/&#\d+;/g, '') // Remove numeric entities
|
||||
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
|
||||
.replace(/[^\w\s]/g, '') // Remove punctuation
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.slice(0, 50); // Compare first 50 chars (article title)
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch remote user posts via Swarm API (preferred for Synapsis nodes)
|
||||
*/
|
||||
const fetchSwarmUserPosts = async (handle: string, domain: string, limit: number) => {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const url = `${protocol}://${domain}/api/swarm/users/${handle}?limit=${limit}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.profile || !data.posts) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOutboxItems = async (outboxUrl: string, limit: number) => {
|
||||
const res = await fetch(outboxUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const first = data?.first;
|
||||
if (first) {
|
||||
if (typeof first === 'string') {
|
||||
const pageRes = await fetch(first, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
});
|
||||
if (!pageRes.ok) return [];
|
||||
const page = await pageRes.json();
|
||||
return page?.orderedItems || page?.items || [];
|
||||
}
|
||||
return first?.orderedItems || first?.items || [];
|
||||
}
|
||||
const items = data?.orderedItems || data?.items || [];
|
||||
return items.slice(0, limit);
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
const remote = parseRemoteHandle(handle);
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.posts) {
|
||||
const profile = swarmData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: authorHandle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = swarmData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile?.outbox) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
|
||||
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: remoteProfile.id || `remote:${authorHandle}`,
|
||||
handle: authorHandle,
|
||||
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
|
||||
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
|
||||
bio: sanitizeText(remoteProfile.summary),
|
||||
};
|
||||
const posts = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenContentKeys = new Set<string>(); // For content-based dedup
|
||||
const origin = new URL(request.url).origin;
|
||||
for (const item of outboxItems) {
|
||||
const activity = item?.type === 'Create' ? item : null;
|
||||
const object = activity?.object;
|
||||
if (!object || typeof object === 'string' || object.type !== 'Note') {
|
||||
continue;
|
||||
}
|
||||
// Deduplicate by object ID
|
||||
const postId = object.id || activity.id;
|
||||
if (seenIds.has(postId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-based dedup: similar content = skip
|
||||
const contentKey = normalizeForDedup(object.content || '');
|
||||
if (seenContentKeys.has(contentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(postId);
|
||||
seenContentKeys.add(contentKey);
|
||||
|
||||
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
|
||||
const { text, urls } = extractTextAndUrls(object.content);
|
||||
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
|
||||
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
|
||||
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
|
||||
posts.push({
|
||||
id: postId,
|
||||
content: contentText || '',
|
||||
createdAt: object.published || activity.published || new Date().toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author,
|
||||
media: attachments
|
||||
.filter((attachment: any) => attachment?.url)
|
||||
.map((attachment: any, index: number) => ({
|
||||
id: `${postId || 'media'}-${index}`,
|
||||
url: attachment.url,
|
||||
altText: sanitizeText(attachment.name) || null,
|
||||
})),
|
||||
linkPreviewUrl: linkPreview?.url || normalizedUrl,
|
||||
linkPreviewTitle: linkPreview?.title || null,
|
||||
linkPreviewDescription: linkPreview?.description || null,
|
||||
linkPreviewImage: linkPreview?.image || null,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.posts) {
|
||||
const profile = swarmData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: authorHandle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = swarmData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile?.outbox) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
|
||||
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
|
||||
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: remoteProfile.id || `remote:${authorHandle}`,
|
||||
handle: authorHandle,
|
||||
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
|
||||
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
|
||||
bio: sanitizeText(remoteProfile.summary),
|
||||
};
|
||||
const posts = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenContentKeys = new Set<string>(); // For content-based dedup
|
||||
const origin = new URL(request.url).origin;
|
||||
for (const item of outboxItems) {
|
||||
const activity = item?.type === 'Create' ? item : null;
|
||||
const object = activity?.object;
|
||||
if (!object || typeof object === 'string' || object.type !== 'Note') {
|
||||
continue;
|
||||
}
|
||||
// Deduplicate by object ID
|
||||
const postId = object.id || activity.id;
|
||||
if (seenIds.has(postId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-based dedup: similar content = skip
|
||||
const contentKey = normalizeForDedup(object.content || '');
|
||||
if (seenContentKeys.has(contentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(postId);
|
||||
seenContentKeys.add(contentKey);
|
||||
|
||||
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
|
||||
const { text, urls } = extractTextAndUrls(object.content);
|
||||
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
|
||||
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
|
||||
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
|
||||
posts.push({
|
||||
id: postId,
|
||||
content: contentText || '',
|
||||
createdAt: object.published || activity.published || new Date().toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author,
|
||||
media: attachments
|
||||
.filter((attachment: any) => attachment?.url)
|
||||
.map((attachment: any, index: number) => ({
|
||||
id: `${postId || 'media'}-${index}`,
|
||||
url: attachment.url,
|
||||
altText: sanitizeText(attachment.name) || null,
|
||||
})),
|
||||
linkPreviewUrl: linkPreview?.url || normalizedUrl,
|
||||
linkPreviewTitle: linkPreview?.title || null,
|
||||
linkPreviewDescription: linkPreview?.description || null,
|
||||
linkPreviewImage: linkPreview?.image || null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts,
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get user's posts
|
||||
let userPosts: any[] = await db.query.posts.findMany({
|
||||
where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
// Populate isLiked and isReposted for authenticated users
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
|
||||
if (session?.user && userPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = userPosts.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));
|
||||
|
||||
userPosts = userPosts.map(p => ({
|
||||
...p,
|
||||
isLiked: likedPostIds.has(p.id),
|
||||
isReposted: repostedPostIds.has(p.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error populating interaction flags:', error);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts: userPosts,
|
||||
nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user posts error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get posts' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { userToActor } from '@/lib/activitypub/actor';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const fetchCollectionCount = async (url?: string | null) => {
|
||||
if (!url) return 0;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
if (typeof data?.totalItems === 'number') return data.totalItems;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch remote user profile via Swarm API (preferred for Synapsis nodes)
|
||||
*/
|
||||
const fetchSwarmProfile = async (handle: string, domain: string) => {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const url = `${protocol}://${domain}/api/swarm/users/${handle}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.profile) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
|
||||
|
||||
// Return mock user if no database
|
||||
if (!db) {
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: 'demo-user',
|
||||
handle: cleanHandle,
|
||||
displayName: cleanHandle,
|
||||
bio: 'This is a demo profile.',
|
||||
avatarUrl: null,
|
||||
headerUrl: null,
|
||||
followersCount: 0,
|
||||
followingCount: 0,
|
||||
postsCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (remoteHandle && remoteDomain) {
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmProfile(remoteHandle, remoteDomain);
|
||||
if (swarmData?.profile) {
|
||||
const profile = swarmData.profile;
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: `swarm:${remoteDomain}:${profile.handle}`,
|
||||
handle: `${profile.handle}@${remoteDomain}`,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
headerUrl: profile.headerUrl || null,
|
||||
followersCount: profile.followersCount,
|
||||
followingCount: profile.followingCount,
|
||||
postsCount: profile.postsCount,
|
||||
website: profile.website || null,
|
||||
createdAt: profile.createdAt,
|
||||
isRemote: true,
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
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;
|
||||
const [followersCount, followingCount, postsCount] = await Promise.all([
|
||||
fetchCollectionCount(remoteProfile.followers),
|
||||
fetchCollectionCount(remoteProfile.following),
|
||||
fetchCollectionCount(remoteProfile.outbox),
|
||||
]);
|
||||
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,
|
||||
followingCount,
|
||||
postsCount,
|
||||
website: profileUrl ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
isRemote: true,
|
||||
profileUrl: profileUrl ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if ActivityPub request
|
||||
const accept = request.headers.get('accept') || '';
|
||||
if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const actor = userToActor(user, nodeDomain);
|
||||
return NextResponse.json(actor, {
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return user profile (without sensitive data)
|
||||
// Include bot info if this is a bot account
|
||||
const userResponse: Record<string, unknown> = {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
headerUrl: user.headerUrl,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
postsCount: user.postsCount,
|
||||
createdAt: user.createdAt,
|
||||
website: user.website,
|
||||
movedTo: user.movedTo,
|
||||
isBot: user.isBot,
|
||||
};
|
||||
|
||||
// If this is a bot, include owner info
|
||||
if (user.isBot && user.botOwnerId) {
|
||||
const owner = await db.query.users.findFirst({
|
||||
where: eq(users.id, user.botOwnerId),
|
||||
});
|
||||
if (owner) {
|
||||
userResponse.botOwner = {
|
||||
id: owner.id,
|
||||
handle: owner.handle,
|
||||
displayName: owner.displayName,
|
||||
avatarUrl: owner.avatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: userResponse });
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db';
|
||||
import { desc, sql } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!db) {
|
||||
return NextResponse.json({ users: [] });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
const userList = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
bio: users.bio,
|
||||
avatarUrl: users.avatarUrl,
|
||||
createdAt: users.createdAt,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.where(sql`${users.isSuspended} IS FALSE`)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({ users: userList });
|
||||
} catch (error) {
|
||||
console.error('List users error:', error);
|
||||
return NextResponse.json({ users: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Post } from '@/lib/types';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { Bot, Network, Server, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
profileUrl?: string | null;
|
||||
isRemote?: boolean;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
return (
|
||||
<Link href={`/${user.handle}`} className="user-card">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} />
|
||||
) : (
|
||||
user.displayName?.charAt(0).toUpperCase() || user.handle.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="user-card-info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span className="user-card-name">{user.displayName || user.handle}</span>
|
||||
{user.isBot && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Bot size={12} />
|
||||
AI Account
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-card-handle">{formatFullHandle(user.handle)}</div>
|
||||
{user.bio && <div className="user-card-bio">{user.bio}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface SwarmPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
author: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
nodeDomain: string;
|
||||
likeCount: number;
|
||||
repostCount: number;
|
||||
replyCount: number;
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
}
|
||||
|
||||
export default function ExplorePage() {
|
||||
const { user } = useAuth();
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
|
||||
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
||||
const [swarmPosts, setSwarmPosts] = useState<SwarmPost[]>([]);
|
||||
const [swarmSources, setSwarmSources] = useState<{ domain: string; postCount: number }[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||
|
||||
// Fetch node info to check if NSFW
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setIsNsfwNode(data.isNsfw || false);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Load node posts (local only)
|
||||
const loadNodePosts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/posts?type=local&limit=20');
|
||||
const data = await res.json();
|
||||
setNodePosts(data.posts || []);
|
||||
} catch {
|
||||
setNodePosts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNodePosts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Load swarm posts when tab changes
|
||||
if (activeTab === 'swarm' && swarmPosts.length === 0) {
|
||||
const loadSwarm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/posts/swarm');
|
||||
const data = await res.json();
|
||||
setSwarmPosts(data.posts || []);
|
||||
setSwarmSources(data.sources || []);
|
||||
} catch {
|
||||
setSwarmPosts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadSwarm();
|
||||
}
|
||||
}, [activeTab, swarmPosts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load users when tab changes to users
|
||||
if (activeTab === 'users' && users.length === 0) {
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/users?limit=20');
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
} catch {
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadUsers();
|
||||
}
|
||||
}, [activeTab, users.length]);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
setSearching(true);
|
||||
setActiveTab('search');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
setSearchResults({
|
||||
posts: data.posts || [],
|
||||
users: data.users || [],
|
||||
});
|
||||
} catch {
|
||||
setSearchResults({ posts: [], users: [] });
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||
const method = currentLiked ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/like`, { method });
|
||||
};
|
||||
|
||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||
const method = currentReposted ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/repost`, { method });
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
setNodePosts(prev => prev.filter(p => p.id !== postId));
|
||||
setSwarmPosts(prev => prev.filter(p => p.id !== postId));
|
||||
setSearchResults(prev => ({
|
||||
...prev,
|
||||
posts: prev.posts.filter(p => p.id !== postId)
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="explore-page">
|
||||
<header className="explore-header">
|
||||
<h1>Explore</h1>
|
||||
<form onSubmit={handleSearch} className="explore-search">
|
||||
<SearchIcon />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search posts and users..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div className="explore-tabs">
|
||||
<button
|
||||
className={`explore-tab ${activeTab === 'node' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('node')}
|
||||
>
|
||||
<Server size={18} />
|
||||
<span>Node</span>
|
||||
</button>
|
||||
<button
|
||||
className={`explore-tab ${activeTab === 'swarm' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('swarm')}
|
||||
>
|
||||
<Network size={18} />
|
||||
<span>Swarm</span>
|
||||
</button>
|
||||
<button
|
||||
className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
<UsersIcon />
|
||||
<span>Users</span>
|
||||
</button>
|
||||
{searchResults.posts.length > 0 || searchResults.users.length > 0 ? (
|
||||
<button
|
||||
className={`explore-tab ${activeTab === 'search' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('search')}
|
||||
>
|
||||
<SearchIcon />
|
||||
<span>Results</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="explore-content">
|
||||
{activeTab === 'node' && (
|
||||
!user && isNsfwNode ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<EyeOff size={48} style={{ marginBottom: '16px', opacity: 0.5 }} />
|
||||
<p style={{ fontSize: '16px', fontWeight: 500, color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
||||
Adult Content
|
||||
</p>
|
||||
<p style={{ fontSize: '14px', maxWidth: '320px', margin: '0 auto' }}>
|
||||
This node contains adult or sensitive content. You must be 18 or older and signed in to view posts.
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="explore-loading">Loading posts...</div>
|
||||
) : nodePosts.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<Server size={24} />
|
||||
<p>No posts on this node yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="feed-meta card">
|
||||
<div className="feed-meta-title">Node feed</div>
|
||||
<div className="feed-meta-body">
|
||||
A chronological feed of all posts from users on this node. See what the local community is sharing.
|
||||
</div>
|
||||
</div>
|
||||
<div className="explore-posts">
|
||||
{nodePosts.map((post) => (
|
||||
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'swarm' && (
|
||||
loading ? (
|
||||
<div className="explore-loading">Loading swarm posts...</div>
|
||||
) : swarmPosts.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<Network size={24} />
|
||||
<p>No swarm posts yet</p>
|
||||
<p style={{ fontSize: '14px', opacity: 0.7, marginTop: '8px' }}>
|
||||
Posts from other Synapsis nodes will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="feed-meta card">
|
||||
<div className="feed-meta-title">Swarm feed</div>
|
||||
<div className="feed-meta-body">
|
||||
Posts from across the Synapsis network. Currently showing posts from {swarmSources.filter(s => s.postCount > 0).length} node{swarmSources.filter(s => s.postCount > 0).length !== 1 ? 's' : ''}.
|
||||
</div>
|
||||
</div>
|
||||
<div className="explore-posts">
|
||||
{swarmPosts.map((post) => {
|
||||
// Transform swarm post to Post format for PostCard
|
||||
const transformedPost: Post = {
|
||||
id: `swarm:${post.nodeDomain}:${post.id}`,
|
||||
originalPostId: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likeCount,
|
||||
repostsCount: post.repostCount,
|
||||
repliesCount: post.replyCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: post.nodeDomain,
|
||||
author: {
|
||||
id: `swarm:${post.nodeDomain}:${post.author.handle}`,
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
avatarUrl: post.author.avatarUrl,
|
||||
},
|
||||
media: post.media?.map((m, idx) => ({
|
||||
id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
mimeType: m.mimeType || null,
|
||||
})) || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
};
|
||||
return (
|
||||
<PostCard
|
||||
key={`${post.nodeDomain}:${post.id}`}
|
||||
post={transformedPost}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
loading ? (
|
||||
<div className="explore-loading">Loading users...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="explore-empty">
|
||||
<UsersIcon />
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-users">
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'search' && (
|
||||
searching ? (
|
||||
<div className="explore-loading">Searching...</div>
|
||||
) : (
|
||||
<div className="explore-search-results">
|
||||
{searchResults.users.length > 0 && (
|
||||
<div className="search-section">
|
||||
<h2>Users</h2>
|
||||
<div className="explore-users">
|
||||
{searchResults.users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{searchResults.posts.length > 0 && (
|
||||
<div className="search-section">
|
||||
<h2>Posts</h2>
|
||||
<div className="explore-posts">
|
||||
{searchResults.posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{searchResults.users.length === 0 && searchResults.posts.length === 0 && (
|
||||
<div className="explore-empty">
|
||||
<SearchIcon />
|
||||
<p>No results found for “{query}”</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ActivityPub Shared Inbox Endpoint
|
||||
*
|
||||
* Receives incoming activities from remote servers.
|
||||
* This is used for batch delivery and public activities.
|
||||
* POST /inbox
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse the activity
|
||||
let activity: IncomingActivity;
|
||||
try {
|
||||
activity = await request.json();
|
||||
} catch (e) {
|
||||
console.error('[SharedInbox] Invalid JSON body:', e);
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[SharedInbox] Received ${activity.type} activity from ${activity.actor}`);
|
||||
|
||||
if (!db) {
|
||||
console.error('[SharedInbox] Database not available');
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Extract headers for signature verification
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
// Get the request path
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// For shared inbox, we need to determine the target user from the activity object
|
||||
let targetUser = null;
|
||||
|
||||
// Try to extract target from the activity object
|
||||
const objectTarget = typeof activity.object === 'string'
|
||||
? activity.object
|
||||
: (activity.object as { id?: string })?.id;
|
||||
|
||||
if (objectTarget) {
|
||||
// Extract handle from target URL (e.g., https://domain.com/users/handle)
|
||||
const handleMatch = objectTarget.match(/\/users\/([^\/]+)/);
|
||||
if (handleMatch) {
|
||||
const handle = handleMatch[1].toLowerCase();
|
||||
targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process the activity
|
||||
const result = await processIncomingActivity(activity, headers, path, targetUser ?? null);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[SharedInbox] Activity processing failed: ${result.error}`);
|
||||
// Don't return error for shared inbox - just log and accept
|
||||
// This is because shared inbox receives activities for multiple users
|
||||
}
|
||||
|
||||
// Return 202 Accepted (standard for ActivityPub)
|
||||
return new NextResponse(null, { status: 202 });
|
||||
} catch (error) {
|
||||
console.error('[SharedInbox] Error processing activity:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityPub requires the inbox to be discoverable
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
summary: 'Shared inbox',
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type EnvStatus = {
|
||||
required: Record<string, boolean>;
|
||||
optional: Record<string, boolean>;
|
||||
};
|
||||
|
||||
type InstallStatus = {
|
||||
env: EnvStatus;
|
||||
db: {
|
||||
connected: boolean;
|
||||
schemaReady: boolean;
|
||||
usersCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
const requiredLabels: Record<string, string> = {
|
||||
DATABASE_URL: 'Database connection string',
|
||||
AUTH_SECRET: 'Auth cookie secret',
|
||||
NEXT_PUBLIC_NODE_DOMAIN: 'Public node domain',
|
||||
NEXT_PUBLIC_NODE_NAME: 'Node display name',
|
||||
ADMIN_EMAILS: 'Admin emails list',
|
||||
};
|
||||
|
||||
const optionalLabels: Record<string, string> = {};
|
||||
|
||||
const StepCard = ({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: { label: string; tone: 'ok' | 'warn' };
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="install-card">
|
||||
<div className="install-card-header">
|
||||
<div>
|
||||
<div className="install-card-title">{title}</div>
|
||||
{description && <div className="install-card-desc">{description}</div>}
|
||||
</div>
|
||||
{status && (
|
||||
<div className={`install-step-status ${status.tone}`}>
|
||||
{status.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function InstallPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const force = searchParams.get('force') === '1';
|
||||
const [status, setStatus] = useState<InstallStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/install/status');
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
const isInstalled = status?.db.connected && status?.db.schemaReady && status?.db.usersCount > 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="install-shell">
|
||||
<div className="install-card">Checking install status...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="install-shell">
|
||||
<div className="install-card">
|
||||
<h1>Setup Wizard</h1>
|
||||
<p>We could not load the install status.</p>
|
||||
<button className="btn btn-primary" onClick={loadStatus}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInstalled && !force) {
|
||||
return (
|
||||
<div className="install-shell">
|
||||
<div className="install-card">
|
||||
<h1>Synapsis is already set up</h1>
|
||||
<p>Your database is connected and at least one user exists.</p>
|
||||
<Link href="/" className="btn btn-primary" style={{ marginTop: '12px' }}>
|
||||
Go to home
|
||||
</Link>
|
||||
<Link href="/install?force=1" className="btn btn-ghost" style={{ marginTop: '12px' }}>
|
||||
Re-run setup
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const missingRequired = Object.entries(status.env.required).filter(([, ok]) => !ok);
|
||||
const missingOptional = Object.entries(status.env.optional).filter(([, ok]) => !ok);
|
||||
const envComplete = missingRequired.length === 0;
|
||||
const dbComplete = status.db.connected && status.db.schemaReady;
|
||||
const adminComplete = status.db.usersCount > 0;
|
||||
const completedSteps = [envComplete, dbComplete, adminComplete].filter(Boolean).length;
|
||||
const progressPercent = Math.round((completedSteps / 3) * 100);
|
||||
|
||||
return (
|
||||
<div className="install-shell">
|
||||
<div className="install-hero">
|
||||
<div className="install-header">
|
||||
<div>
|
||||
<h1>Synapsis Setup</h1>
|
||||
<p>Follow these steps to complete your installation.</p>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={loadStatus}>
|
||||
Recheck
|
||||
</button>
|
||||
</div>
|
||||
<div className="install-progress">
|
||||
<div className="install-progress-bar">
|
||||
<div className="install-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
<div className="install-progress-meta">
|
||||
<span>{completedSteps} / 3 steps complete</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="install-summary">
|
||||
<div>
|
||||
<div className="install-summary-title">Environment</div>
|
||||
<div className="install-summary-value">{envComplete ? 'Ready' : 'Needs values'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="install-summary-title">Database</div>
|
||||
<div className="install-summary-value">{dbComplete ? 'Ready' : 'Not ready'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="install-summary-title">Admin</div>
|
||||
<div className="install-summary-value">{adminComplete ? 'Ready' : 'Not created'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepCard
|
||||
title="1. Environment variables"
|
||||
description="Set required values in your deployment environment (.env or platform settings)."
|
||||
status={{
|
||||
label: envComplete ? 'Complete' : 'Needs attention',
|
||||
tone: envComplete ? 'ok' : 'warn',
|
||||
}}
|
||||
>
|
||||
<div className="install-grid">
|
||||
{Object.entries(status.env.required).map(([key, ok]) => (
|
||||
<div key={key} className={`install-item ${ok ? 'ok' : 'missing'}`}>
|
||||
<div className="install-item-title">{key}</div>
|
||||
<div className="install-item-desc">{requiredLabels[key]}</div>
|
||||
<div className="install-item-status">{ok ? 'Set' : 'Missing'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{missingRequired.length > 0 && (
|
||||
<div className="install-hint">
|
||||
Missing required values: {missingRequired.map(([key]) => key).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{missingRequired.some(([key]) => key === 'AUTH_SECRET') && (
|
||||
<div className="install-hint" style={{ marginTop: '8px' }}>
|
||||
To generate an AUTH_SECRET, run:
|
||||
<code style={{ display: 'block', padding: '8px', background: 'rgba(0,0,0,0.05)', marginTop: '4px', borderRadius: '4px', fontFamily: 'monospace' }}>
|
||||
openssl rand -base64 33
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="install-subtitle">Optional</div>
|
||||
<div className="install-grid">
|
||||
{Object.entries(status.env.optional).map(([key, ok]) => (
|
||||
<div key={key} className={`install-item ${ok ? 'ok' : 'missing'}`}>
|
||||
<div className="install-item-title">{key}</div>
|
||||
<div className="install-item-desc">{optionalLabels[key]}</div>
|
||||
<div className="install-item-status">{ok ? 'Set' : 'Not set'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{missingOptional.length > 0 && (
|
||||
<div className="install-hint">
|
||||
Optional values missing: {missingOptional.map(([key]) => key).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</StepCard>
|
||||
|
||||
<StepCard
|
||||
title="2. Database"
|
||||
description="Ensure the database is reachable and the schema is pushed."
|
||||
status={{
|
||||
label: dbComplete ? 'Complete' : 'Needs attention',
|
||||
tone: dbComplete ? 'ok' : 'warn',
|
||||
}}
|
||||
>
|
||||
<div className="install-status-row">
|
||||
<span>Database connection</span>
|
||||
<strong>{status.db.connected ? 'Connected' : 'Not connected'}</strong>
|
||||
</div>
|
||||
<div className="install-status-row">
|
||||
<span>Schema</span>
|
||||
<strong>{status.db.schemaReady ? 'Ready' : 'Missing tables'}</strong>
|
||||
</div>
|
||||
{!status.db.schemaReady && (
|
||||
<div className="install-hint">
|
||||
Run <code>npm run db:push</code> to create tables.
|
||||
</div>
|
||||
)}
|
||||
</StepCard>
|
||||
|
||||
<StepCard
|
||||
title="3. Create admin account"
|
||||
description="Register your first account, then grant admin access."
|
||||
status={{
|
||||
label: adminComplete ? 'Complete' : 'Needs attention',
|
||||
tone: adminComplete ? 'ok' : 'warn',
|
||||
}}
|
||||
>
|
||||
<div className="install-status-row">
|
||||
<span>Existing users</span>
|
||||
<strong>{status.db.usersCount}</strong>
|
||||
</div>
|
||||
<p className="install-body">
|
||||
Register a user via the login page. Then add their email to
|
||||
<code>ADMIN_EMAILS</code> and redeploy.
|
||||
</p>
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
Go to login / register
|
||||
</Link>
|
||||
</StepCard>
|
||||
|
||||
<StepCard
|
||||
title="4. Launch"
|
||||
description="Once you have at least one user and admin access, you are ready."
|
||||
>
|
||||
<Link href="/" className="btn btn-primary">
|
||||
Go to home
|
||||
</Link>
|
||||
<Link href="/admin" className="btn btn-ghost">
|
||||
Open moderation dashboard
|
||||
</Link>
|
||||
</StepCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Saira_Condensed } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
const sairaCondensed = Saira_Condensed({
|
||||
subsets: ["latin"],
|
||||
weight: ["700"],
|
||||
variable: "--font-saira",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Synapsis",
|
||||
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/api/favicon",
|
||||
},
|
||||
themeColor: "#0a0a0a",
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||
};
|
||||
|
||||
// Force all routes to be dynamic (no static generation at build time)
|
||||
// This is appropriate for a social network where all content is user-generated
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// This is appropriate for a social network where all content is user-generated
|
||||
|
||||
import { AuthProvider } from '@/lib/contexts/AuthContext';
|
||||
import { ToastProvider } from '@/lib/contexts/ToastContext';
|
||||
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
|
||||
import { LayoutWrapper } from '@/components/LayoutWrapper';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<AccentColorProvider>
|
||||
<ToastProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
</ToastProvider>
|
||||
</AccentColorProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [handle, setHandle] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
|
||||
const [nodeInfo, setNodeInfo] = useState<{ name: string; description: string; logoUrl?: string; isNsfw?: boolean }>({ name: '', description: '' });
|
||||
const [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
|
||||
const [ageVerified, setAgeVerified] = useState(false);
|
||||
|
||||
// Import specific state
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importPassword, setImportPassword] = useState('');
|
||||
const [importHandle, setImportHandle] = useState('');
|
||||
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
|
||||
const [importAgeVerified, setImportAgeVerified] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
||||
|
||||
// Fetch node info
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setNodeInfo({
|
||||
name: data.name || '',
|
||||
description: data.description || 'Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental. Synapsis aims to be neutral, resilient infrastructure for human and machine discourse, more like a protocol or nervous system than a social club.',
|
||||
logoUrl: data.logoUrl || undefined,
|
||||
isNsfw: data.isNsfw || false
|
||||
});
|
||||
// Update page title
|
||||
if (data.name && data.name !== 'Synapsis') {
|
||||
document.title = data.name;
|
||||
}
|
||||
setNodeInfoLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setNodeInfoLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle availability check
|
||||
useEffect(() => {
|
||||
const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : '');
|
||||
if (!checkHandle || checkHandle.length < 3) {
|
||||
setHandleStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setHandleStatus('checking');
|
||||
try {
|
||||
const res = await fetch(`/api/auth/check-handle?handle=${checkHandle}`);
|
||||
const data = await res.json();
|
||||
if (data.available) {
|
||||
setHandleStatus('available');
|
||||
} else {
|
||||
setHandleStatus('taken');
|
||||
}
|
||||
} catch {
|
||||
setHandleStatus('idle');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [handle, importHandle, mode]);
|
||||
|
||||
const handleImport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!importFile || !importPassword || !importHandle || !acceptedCompliance) {
|
||||
setError('Please fill in all fields and accept the compliance agreement');
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeInfo.isNsfw && !importAgeVerified) {
|
||||
setError('You must verify your age to import an account on this node');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setImportSuccess(null);
|
||||
|
||||
try {
|
||||
const fileContent = await importFile.text();
|
||||
const exportData = JSON.parse(fileContent);
|
||||
|
||||
const res = await fetch('/api/account/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
exportData,
|
||||
password: importPassword,
|
||||
newHandle: importHandle,
|
||||
acceptedCompliance,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Import failed');
|
||||
}
|
||||
|
||||
setImportSuccess(data.message);
|
||||
// Hard redirect to ensure cookie is picked up
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (mode === 'register' && password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'register' && nodeInfo.isNsfw && !ageVerified) {
|
||||
setError('You must verify your age to register on this node');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||
const body = mode === 'login'
|
||||
? { email, password }
|
||||
: { email, password, handle, displayName };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Hard redirect to ensure cookie is picked up
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
|
||||
{nodeInfoLoaded && (
|
||||
<>
|
||||
{nodeInfo.logoUrl ? (
|
||||
<Image
|
||||
src={nodeInfo.logoUrl}
|
||||
alt={nodeInfo.name || 'Node logo'}
|
||||
width={200}
|
||||
height={60}
|
||||
style={{ marginBottom: '16px', objectFit: 'contain', maxHeight: '60px', width: 'auto' }}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src="/logotext.svg"
|
||||
alt="Synapsis"
|
||||
width={200}
|
||||
height={48}
|
||||
style={{ marginBottom: '16px', objectFit: 'contain' }}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
{nodeInfo.name && nodeInfo.name !== 'Synapsis' && !nodeInfo.logoUrl && (
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: 'var(--foreground)', marginBottom: '8px' }}>
|
||||
{nodeInfo.name}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ color: 'var(--foreground-secondary)', marginTop: '0', textAlign: 'center' }}>
|
||||
{nodeInfo.description}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode Switcher */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
marginBottom: '24px',
|
||||
background: 'var(--background-secondary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '4px',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setMode('login')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: mode === 'login' ? 'var(--background-tertiary)' : 'transparent',
|
||||
color: mode === 'login' ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('register')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: mode === 'register' ? 'var(--background-tertiary)' : 'transparent',
|
||||
color: mode === 'register' ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('import')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: mode === 'import' ? 'var(--background-tertiary)' : 'transparent',
|
||||
color: mode === 'import' ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{mode !== 'import' ? (
|
||||
<form onSubmit={handleSubmit} className="card" style={{ padding: '24px' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--error)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Handle
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>@</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||
style={{ paddingLeft: '28px' }}
|
||||
placeholder="yourhandle"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>
|
||||
3-20 characters, alphanumeric and underscores
|
||||
</span>
|
||||
{handleStatus === 'checking' && (
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span>
|
||||
)}
|
||||
{handleStatus === 'available' && (
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>Available</span>
|
||||
)}
|
||||
{handleStatus === 'taken' && (
|
||||
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'register' && nodeInfo.isNsfw && (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '12px',
|
||||
background: 'rgba(239, 68, 68, 0.05)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ageVerified}
|
||||
onChange={(e) => setAgeVerified(e.target.checked)}
|
||||
style={{ marginTop: '3px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: 'var(--error)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<TriangleAlert size={12} /> Age Verification:
|
||||
</strong> This node contains adult or sensitive content. I confirm that I am at least 18 years of age.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleImport} className="card" style={{ padding: '24px' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--error)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importSuccess && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
background: 'var(--success)',
|
||||
border: '1px solid var(--success)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: '#000',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{importSuccess} Redirecting...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Export file
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="file"
|
||||
id="import-file-input"
|
||||
accept=".json"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label
|
||||
htmlFor="import-file-input"
|
||||
className="input"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
color: importFile ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<span>{importFile ? importFile.name : 'Select export file...'}</span>
|
||||
<span className="btn btn-ghost btn-sm" style={{ pointerEvents: 'none', padding: '4px 8px', height: 'auto', minHeight: 'unset' }}>
|
||||
Browse
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Password (from your old account)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter the password for this account"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Handle on this node
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>@</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={importHandle}
|
||||
onChange={(e) => setImportHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||
style={{ paddingLeft: '28px' }}
|
||||
placeholder="yourhandle"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>
|
||||
3-20 chars
|
||||
</span>
|
||||
{handleStatus === 'checking' && (
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span>
|
||||
)}
|
||||
{handleStatus === 'available' && (
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>Available</span>
|
||||
)}
|
||||
{handleStatus === 'taken' && (
|
||||
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '12px',
|
||||
background: 'rgba(245, 158, 11, 0.05)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedCompliance}
|
||||
onChange={(e) => setAcceptedCompliance(e.target.checked)}
|
||||
style={{ marginTop: '3px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<TriangleAlert size={12} /> Compliance:
|
||||
</strong> I agree to comply with this node's rules and take responsibility for my migrated content.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{nodeInfo.isNsfw && (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '12px',
|
||||
background: 'rgba(239, 68, 68, 0.05)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importAgeVerified}
|
||||
onChange={(e) => setImportAgeVerified(e.target.checked)}
|
||||
style={{ marginTop: '3px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: 'var(--error)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<TriangleAlert size={12} /> Age Verification:
|
||||
</strong> This node contains adult or sensitive content. I confirm that I am at least 18 years of age.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
|
||||
>
|
||||
{loading ? 'Importing...' : 'Import Account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
<Link href="/">← Back to home</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, posts } from '@/db';
|
||||
import { count } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
|
||||
const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
|
||||
|
||||
// Get stats
|
||||
const [userCount] = await db.select({ count: count() }).from(users);
|
||||
const [postCount] = await db.select({ count: count() }).from(posts);
|
||||
|
||||
return NextResponse.json({
|
||||
version: '2.1',
|
||||
software: {
|
||||
name: 'synapsis',
|
||||
version: '0.1.0',
|
||||
homepage: 'https://github.com/synapsis',
|
||||
},
|
||||
protocols: ['activitypub'],
|
||||
usage: {
|
||||
users: {
|
||||
total: userCount?.count || 0,
|
||||
activeMonth: userCount?.count || 0,
|
||||
activeHalfyear: userCount?.count || 0,
|
||||
},
|
||||
localPosts: postCount?.count || 0,
|
||||
},
|
||||
openRegistrations: true,
|
||||
metadata: {
|
||||
nodeName,
|
||||
nodeDescription,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { BellIcon } from '@/components/Icons';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="notifications-page">
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--background)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Notifications</h1>
|
||||
</header>
|
||||
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, background: 'var(--background-secondary)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<BellIcon />
|
||||
</div>
|
||||
</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px', color: 'var(--foreground)' }}>No notifications yet</h3>
|
||||
<p style={{ fontSize: '14px' }}>When you get interactions, they'll show up here.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Compose } from '@/components/Compose';
|
||||
import { Post } from '@/lib/types';
|
||||
import { EyeOff } from 'lucide-react';
|
||||
|
||||
interface FeedMeta {
|
||||
score: number;
|
||||
reasons: string[];
|
||||
engagement: {
|
||||
likes: number;
|
||||
reposts: number;
|
||||
replies: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { user } = useAuth();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
|
||||
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
|
||||
const [feedMeta, setFeedMeta] = useState<{
|
||||
algorithm: string;
|
||||
windowHours: number;
|
||||
seedLimit: number;
|
||||
weights: {
|
||||
engagement: number;
|
||||
recency: number;
|
||||
followBoost: number;
|
||||
selfBoost: number;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
// Fetch node info to check if NSFW
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setIsNsfwNode(data.isNsfw || false);
|
||||
setNodeInfoLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setNodeInfoLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadFeed = async (type: 'latest' | 'curated') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const endpoint = type === 'curated' ? '/api/posts?type=curated' : '/api/posts?type=home';
|
||||
const res = await fetch(endpoint);
|
||||
const data = await res.json();
|
||||
setPosts(data.posts || []);
|
||||
setFeedMeta(data.meta || null);
|
||||
} catch {
|
||||
setPosts([]);
|
||||
setFeedMeta(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFeed(feedType);
|
||||
}, [feedType]);
|
||||
|
||||
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
|
||||
// Check if we're replying to a swarm post
|
||||
let swarmReplyTo: { postId: string; nodeDomain: string } | undefined;
|
||||
let localReplyToId: string | undefined = replyToId;
|
||||
|
||||
if (replyingTo?.isSwarm && replyingTo.nodeDomain && replyingTo.originalPostId) {
|
||||
// This is a reply to a swarm post - send to the origin node
|
||||
swarmReplyTo = {
|
||||
postId: replyingTo.originalPostId,
|
||||
nodeDomain: replyingTo.nodeDomain,
|
||||
};
|
||||
localReplyToId = undefined; // Don't set local replyToId for swarm posts
|
||||
}
|
||||
|
||||
const res = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (feedType === 'curated') {
|
||||
loadFeed('curated');
|
||||
} else {
|
||||
setPosts([{ ...data.post, author: user }, ...posts]);
|
||||
}
|
||||
setReplyingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||
const method = currentLiked ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/like`, { method });
|
||||
};
|
||||
|
||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||
const method = currentReposted ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/repost`, { method });
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--background)',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1>
|
||||
<div className="feed-toggle">
|
||||
<button
|
||||
className={`feed-toggle-btn ${feedType === 'latest' ? 'active' : ''}`}
|
||||
onClick={() => setFeedType('latest')}
|
||||
>
|
||||
Latest
|
||||
</button>
|
||||
<button
|
||||
className={`feed-toggle-btn ${feedType === 'curated' ? 'active' : ''}`}
|
||||
onClick={() => setFeedType('curated')}
|
||||
>
|
||||
Curated
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{user && (
|
||||
<Compose
|
||||
onPost={handlePost}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
|
||||
Join Synapsis to post and interact
|
||||
</p>
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
Login or Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedType === 'curated' && feedMeta && (
|
||||
<div className="feed-meta card">
|
||||
<div className="feed-meta-title">Curated feed</div>
|
||||
<div className="feed-meta-body">
|
||||
This feed highlights fresh posts and active discussions, with a boost for people you follow. It is designed to surface what matters without hiding your own activity.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NSFW node gate for unauthenticated users */}
|
||||
{!user && !nodeInfoLoaded ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : !user && isNsfwNode ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<EyeOff size={48} style={{ marginBottom: '16px', opacity: 0.5 }} />
|
||||
<p style={{ fontSize: '16px', fontWeight: 500, color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
||||
Adult Content
|
||||
</p>
|
||||
<p style={{ fontSize: '14px', maxWidth: '320px', margin: '0 auto' }}>
|
||||
This node contains adult or sensitive content. You must be 18 or older and signed in to view posts.
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No posts yet</p>
|
||||
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
|
||||
</div>
|
||||
) : (
|
||||
posts.map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onDelete={handleDelete}
|
||||
onComment={(p) => {
|
||||
setReplyingTo(p);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Post } from '@/lib/types';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
profileUrl?: string | null;
|
||||
isRemote?: boolean;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Icons
|
||||
const SearchIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowLeftIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const HeartIcon = ({ filled }: { filled?: boolean }) => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RepeatIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MessageIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FlagIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 5v16" />
|
||||
<path d="M5 5h11l-1 4 1 4H5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/@${user.handle}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
className="hover-bg"
|
||||
>
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{user.displayName || user.handle}</span>
|
||||
{user.isBot && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Bot size={12} />
|
||||
AI Account
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>{formatFullHandle(user.handle)}</div>
|
||||
{user.bio && (
|
||||
<div style={{
|
||||
color: 'var(--foreground-secondary)',
|
||||
fontSize: '14px',
|
||||
marginTop: '4px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{user.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get('q') || '';
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'users' | 'posts'>('all');
|
||||
|
||||
const search = useCallback(async (q: string, type: string = 'all') => {
|
||||
if (!q.trim()) {
|
||||
setUsers([]);
|
||||
setPosts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&type=${type}`);
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
setPosts(data.posts || []);
|
||||
} catch {
|
||||
console.error('Search failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery) {
|
||||
search(initialQuery, activeTab);
|
||||
}
|
||||
}, [initialQuery, activeTab, search]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
search(query, activeTab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: 'all' | 'users' | 'posts') => {
|
||||
setActiveTab(tab);
|
||||
if (query.trim()) {
|
||||
search(query, tab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||
const method = currentLiked ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/like`, { method });
|
||||
};
|
||||
|
||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||
const method = currentReposted ? 'DELETE' : 'POST';
|
||||
await fetch(`/api/posts/${postId}/repost`, { method });
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--background)',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Link href="/" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<form onSubmit={handleSubmit} style={{ flex: 1 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search users and posts..."
|
||||
style={{ paddingLeft: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)' }}>
|
||||
{(['all', 'users', 'posts'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
color: activeTab === tab ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{loading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Searching...
|
||||
</div>
|
||||
) : !initialQuery ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Search for users and posts</p>
|
||||
</div>
|
||||
) : users.length === 0 && posts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No results for “{initialQuery}”</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Users */}
|
||||
{(activeTab === 'all' || activeTab === 'users') && users.length > 0 && (
|
||||
<div>
|
||||
{activeTab === 'all' && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
fontWeight: 600,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--background-secondary)',
|
||||
}}>
|
||||
Users
|
||||
</div>
|
||||
)}
|
||||
{users.map(user => <UserCard key={user.id} user={user} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{(activeTab === 'all' || activeTab === 'posts') && posts.length > 0 && (
|
||||
<div>
|
||||
{activeTab === 'all' && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
fontWeight: 600,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--background-secondary)',
|
||||
}}>
|
||||
Posts
|
||||
</div>
|
||||
)}
|
||||
{posts.map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* Bot Detail/Edit Page
|
||||
*
|
||||
* View and edit bot configuration, manage sources, view logs.
|
||||
*
|
||||
* Requirements: 1.3, 4.6, 8.2
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { Bot, Play, Pause, Rss, Activity, Settings, Sparkles, Clock, Trash2, Pencil } from 'lucide-react';
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
|
||||
export default function BotDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const botId = params.id as string;
|
||||
const [bot, setBot] = useState<any>(null);
|
||||
const [sources, setSources] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [showAddSource, setShowAddSource] = useState(false);
|
||||
const [newSource, setNewSource] = useState({
|
||||
type: 'rss' as 'rss' | 'reddit' | 'news_api' | 'brave_news',
|
||||
url: '',
|
||||
subreddit: '',
|
||||
apiKey: '',
|
||||
// Brave News config
|
||||
braveQuery: '',
|
||||
braveFreshness: 'pw' as 'pd' | 'pw' | 'pm' | 'py',
|
||||
braveCountry: '',
|
||||
// News API config
|
||||
newsProvider: 'newsapi' as 'newsapi' | 'gnews' | 'newsdata',
|
||||
newsQuery: '',
|
||||
newsCategory: '',
|
||||
newsCountry: '',
|
||||
newsLanguage: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchBot();
|
||||
fetchSources();
|
||||
}, [botId]);
|
||||
|
||||
const fetchBot = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/bots/${botId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBot(data.bot);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bot:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/bots/${botId}/sources`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSources(data.sources || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sources:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerPost = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/bots/${botId}/post`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast('Post triggered successfully!', 'success');
|
||||
fetchBot();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showToast(`Failed to trigger post: ${data.error || 'Unknown error'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to trigger post', 'error');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/bots/${botId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !bot.isActive }),
|
||||
});
|
||||
if (response.ok) {
|
||||
fetchBot();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle bot:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSource = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
let url = newSource.url;
|
||||
const payload: Record<string, unknown> = {
|
||||
type: newSource.type,
|
||||
apiKey: newSource.apiKey || undefined,
|
||||
};
|
||||
|
||||
// Build URL and config based on type
|
||||
if (newSource.type === 'brave_news') {
|
||||
if (!newSource.braveQuery || !newSource.apiKey) {
|
||||
showToast('Search query and API key are required for Brave News', 'error');
|
||||
setActionLoading(false);
|
||||
return;
|
||||
}
|
||||
const braveUrl = new URL('https://api.search.brave.com/res/v1/news/search');
|
||||
braveUrl.searchParams.set('q', newSource.braveQuery);
|
||||
if (newSource.braveFreshness) braveUrl.searchParams.set('freshness', newSource.braveFreshness);
|
||||
if (newSource.braveCountry) braveUrl.searchParams.set('country', newSource.braveCountry);
|
||||
url = braveUrl.toString();
|
||||
payload.braveNewsConfig = {
|
||||
query: newSource.braveQuery,
|
||||
freshness: newSource.braveFreshness,
|
||||
country: newSource.braveCountry || undefined,
|
||||
};
|
||||
} else if (newSource.type === 'news_api') {
|
||||
if (!newSource.newsQuery || !newSource.apiKey) {
|
||||
showToast('Search query and API key are required for News API', 'error');
|
||||
setActionLoading(false);
|
||||
return;
|
||||
}
|
||||
let baseUrl: string;
|
||||
const params = new URLSearchParams();
|
||||
switch (newSource.newsProvider) {
|
||||
case 'gnews':
|
||||
baseUrl = 'https://gnews.io/api/v4/search';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
||||
break;
|
||||
case 'newsdata':
|
||||
baseUrl = 'https://newsdata.io/api/1/news';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
||||
break;
|
||||
default:
|
||||
baseUrl = 'https://newsapi.org/v2/everything';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
}
|
||||
url = `${baseUrl}?${params.toString()}`;
|
||||
payload.newsApiConfig = {
|
||||
provider: newSource.newsProvider,
|
||||
query: newSource.newsQuery,
|
||||
category: newSource.newsCategory || undefined,
|
||||
country: newSource.newsCountry || undefined,
|
||||
language: newSource.newsLanguage || undefined,
|
||||
};
|
||||
} else if (newSource.type === 'reddit') {
|
||||
payload.subreddit = newSource.subreddit || undefined;
|
||||
}
|
||||
|
||||
payload.url = url;
|
||||
|
||||
const response = await fetch(`/api/bots/${botId}/sources`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (response.ok) {
|
||||
setShowAddSource(false);
|
||||
setNewSource({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
subreddit: '',
|
||||
apiKey: '',
|
||||
braveQuery: '',
|
||||
braveFreshness: 'pw',
|
||||
braveCountry: '',
|
||||
newsProvider: 'newsapi',
|
||||
newsQuery: '',
|
||||
newsCategory: '',
|
||||
newsCountry: '',
|
||||
newsLanguage: '',
|
||||
});
|
||||
fetchSources();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showToast(`Failed to add source: ${data.error || 'Unknown error'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to add source', 'error');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchSource = async (sourceId: string) => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/bots/${botId}/sources/${sourceId}/fetch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showToast(`Fetched ${data.itemsFetched || 0} items successfully!`, 'success');
|
||||
fetchSources();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showToast(`Failed to fetch content: ${data.error || 'Unknown error'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to fetch content', 'error');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bot) {
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--foreground-tertiary)' }}>Bot not found</p>
|
||||
<Link href="/settings/bots" className="btn" style={{ marginTop: '16px' }}>
|
||||
Back to Bots
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scheduleConfig = typeof bot.scheduleConfig === 'string'
|
||||
? JSON.parse(bot.scheduleConfig)
|
||||
: bot.scheduleConfig || null;
|
||||
const personalityConfig = typeof bot.personalityConfig === 'string'
|
||||
? JSON.parse(bot.personalityConfig)
|
||||
: bot.personalityConfig || {};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
|
||||
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>{bot.name}</h1>
|
||||
{bot.autonomousMode && (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '11px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
}}>
|
||||
<Sparkles size={12} />
|
||||
Autonomous
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '14px', color: 'var(--foreground-tertiary)' }}>
|
||||
@{bot.handle}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{bot.isSuspended ? (
|
||||
<span className="status-pill suspended">Suspended</span>
|
||||
) : bot.isActive ? (
|
||||
<span className="status-pill active">Active</span>
|
||||
) : (
|
||||
<span className="status-pill">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Play size={18} />
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleTriggerPost}
|
||||
disabled={actionLoading || bot.isSuspended}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Play size={16} />
|
||||
Trigger Post
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleActive}
|
||||
disabled={actionLoading || bot.isSuspended}
|
||||
className="btn"
|
||||
>
|
||||
{bot.isActive ? <Pause size={16} /> : <Play size={16} />}
|
||||
{bot.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<Link
|
||||
href={`/settings/bots/${botId}/edit`}
|
||||
className="btn"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit Bot
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bot Info */}
|
||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Bot size={18} />
|
||||
Bot Information
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{bot.bio && (
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
|
||||
Bio
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
|
||||
{bot.bio}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
|
||||
Last Post
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{bot.lastPostAt ? new Date(bot.lastPostAt).toLocaleString() : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
|
||||
Created
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{new Date(bot.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
|
||||
LLM Provider
|
||||
</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{bot.llmProvider} / {bot.llmModel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
{bot.autonomousMode && scheduleConfig && (
|
||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Clock size={18} />
|
||||
Posting Schedule
|
||||
</h2>
|
||||
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
|
||||
{scheduleConfig.type === 'interval' && scheduleConfig.intervalMinutes
|
||||
? `Posts every ${scheduleConfig.intervalMinutes} minutes`
|
||||
: 'Custom schedule configured'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personality */}
|
||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Sparkles size={18} />
|
||||
Personality
|
||||
</h2>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--foreground-secondary)',
|
||||
background: 'var(--background-tertiary)',
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{personalityConfig?.systemPrompt || 'No system prompt configured'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Sources */}
|
||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Rss size={18} />
|
||||
Content Sources ({sources.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddSource(!showAddSource)}
|
||||
className="btn btn-sm btn-primary"
|
||||
>
|
||||
{showAddSource ? 'Cancel' : '+ Add Source'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddSource && (
|
||||
<div className="card" style={{ padding: '16px', marginBottom: '16px', background: 'var(--background-tertiary)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Source Type
|
||||
</label>
|
||||
<select
|
||||
value={newSource.type}
|
||||
onChange={(e) => setNewSource({ ...newSource, type: e.target.value as typeof newSource.type })}
|
||||
className="input"
|
||||
>
|
||||
<option value="rss">RSS Feed</option>
|
||||
<option value="reddit">Reddit</option>
|
||||
<option value="brave_news">Brave News Search</option>
|
||||
<option value="news_api">News API (Advanced)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{newSource.type === 'rss' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
RSS Feed URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newSource.url}
|
||||
onChange={(e) => setNewSource({ ...newSource, url: e.target.value })}
|
||||
className="input"
|
||||
placeholder="https://example.com/feed.xml"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSource.type === 'reddit' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Subreddit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.subreddit}
|
||||
onChange={(e) => setNewSource({ ...newSource, subreddit: e.target.value, url: `https://reddit.com/r/${e.target.value}` })}
|
||||
className="input"
|
||||
placeholder="technology"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSource.type === 'brave_news' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Search Query
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.braveQuery}
|
||||
onChange={(e) => setNewSource({ ...newSource, braveQuery: e.target.value })}
|
||||
className="input"
|
||||
placeholder="AI technology, climate change, etc."
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Use quotes for exact phrases, minus to exclude terms
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Freshness
|
||||
</label>
|
||||
<select
|
||||
value={newSource.braveFreshness}
|
||||
onChange={(e) => setNewSource({ ...newSource, braveFreshness: e.target.value as typeof newSource.braveFreshness })}
|
||||
className="input"
|
||||
>
|
||||
<option value="pd">Last 24 hours</option>
|
||||
<option value="pw">Last 7 days</option>
|
||||
<option value="pm">Last 31 days</option>
|
||||
<option value="py">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Country (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newSource.braveCountry}
|
||||
onChange={(e) => setNewSource({ ...newSource, braveCountry: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All countries</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="JP">Japan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Brave API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newSource.apiKey}
|
||||
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Your Brave Search API key"
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Get your API key at <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>brave.com/search/api</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{newSource.type === 'news_api' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
News Provider
|
||||
</label>
|
||||
<select
|
||||
value={newSource.newsProvider}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsProvider: e.target.value as typeof newSource.newsProvider })}
|
||||
className="input"
|
||||
>
|
||||
<option value="newsapi">NewsAPI.org</option>
|
||||
<option value="gnews">GNews.io</option>
|
||||
<option value="newsdata">NewsData.io</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Search Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.newsQuery}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsQuery: e.target.value })}
|
||||
className="input"
|
||||
placeholder="technology, AI, startups"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Category (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newSource.newsCategory}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsCategory: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
<option value="technology">Technology</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="science">Science</option>
|
||||
<option value="health">Health</option>
|
||||
<option value="sports">Sports</option>
|
||||
<option value="entertainment">Entertainment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Country (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newSource.newsCountry}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsCountry: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All countries</option>
|
||||
<option value="us">United States</option>
|
||||
<option value="gb">United Kingdom</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="de">Germany</option>
|
||||
<option value="fr">France</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newSource.apiKey}
|
||||
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Your API key"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAddSource}
|
||||
disabled={
|
||||
actionLoading ||
|
||||
(newSource.type === 'rss' && !newSource.url) ||
|
||||
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
||||
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
|
||||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
||||
}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{actionLoading ? 'Adding...' : 'Add Source'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', textAlign: 'center', padding: '24px 0' }}>
|
||||
No content sources configured. Add a source to enable posting.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{sources.map((source: any) => {
|
||||
let displayName = '';
|
||||
try {
|
||||
if (source.type === 'brave_news') {
|
||||
const urlObj = new URL(source.url);
|
||||
displayName = urlObj.searchParams.get('q') || 'Brave News';
|
||||
} else if (source.subreddit) {
|
||||
displayName = source.subreddit;
|
||||
} else if (source.url) {
|
||||
displayName = new URL(source.url).hostname;
|
||||
} else {
|
||||
displayName = 'Unknown';
|
||||
}
|
||||
} catch {
|
||||
displayName = source.url || 'Unknown';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'var(--background-tertiary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px' }}>
|
||||
{source.type === 'brave_news' ? 'BRAVE NEWS' : source.type.toUpperCase()} - {displayName}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||
{source.lastFetchAt ? `Last fetched: ${new Date(source.lastFetchAt).toLocaleString()}` : 'Never fetched'}
|
||||
{source.lastError && <span style={{ color: 'var(--error)' }}> • Error: {source.lastError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-pill ${source.isActive ? 'active' : ''}`}>
|
||||
{source.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="card" style={{ padding: '20px', borderColor: 'var(--error)' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', color: 'var(--error)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Trash2 size={18} />
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginBottom: '12px' }}>
|
||||
Deleting a bot is permanent and cannot be undone. All associated data will be removed.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
|
||||
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
|
||||
.then(() => router.push('/settings/bots'))
|
||||
.catch(() => showToast('Failed to delete bot', 'error'));
|
||||
}
|
||||
}}
|
||||
className="btn"
|
||||
style={{ color: 'var(--error)', borderColor: 'var(--error)' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
/**
|
||||
* Bot Creation Page
|
||||
*
|
||||
* Form for creating a new bot.
|
||||
*
|
||||
* Requirements: 1.1, 2.1, 3.1
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
||||
|
||||
interface ContentSource {
|
||||
type: 'rss' | 'reddit' | 'news_api' | 'brave_news' | 'youtube';
|
||||
url: string;
|
||||
subreddit?: string;
|
||||
apiKey?: string;
|
||||
// Brave News config
|
||||
braveQuery?: string;
|
||||
braveFreshness?: 'pd' | 'pw' | 'pm' | 'py';
|
||||
braveCountry?: string;
|
||||
// News API config
|
||||
newsProvider?: 'newsapi' | 'gnews' | 'newsdata';
|
||||
newsQuery?: string;
|
||||
newsCategory?: string;
|
||||
newsCountry?: string;
|
||||
newsLanguage?: string;
|
||||
// YouTube config
|
||||
youtubeChannelId?: string;
|
||||
youtubePlaylistId?: string;
|
||||
}
|
||||
|
||||
export default function NewBotPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
handle: '',
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
headerUrl: '',
|
||||
systemPrompt: '',
|
||||
llmProvider: 'openai',
|
||||
llmModel: 'gpt-4',
|
||||
llmApiKey: '',
|
||||
autonomousMode: false,
|
||||
postingFrequency: 'hourly',
|
||||
customIntervalMinutes: 60,
|
||||
});
|
||||
|
||||
const [sources, setSources] = useState<ContentSource[]>([]);
|
||||
const [newSource, setNewSource] = useState<ContentSource>({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
braveQuery: '',
|
||||
braveFreshness: 'pw',
|
||||
newsProvider: 'newsapi',
|
||||
newsQuery: '',
|
||||
});
|
||||
|
||||
// Fetch previous bot settings to pre-fill LLM config
|
||||
useEffect(() => {
|
||||
const fetchPreviousBotSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bots');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.bots && data.bots.length > 0) {
|
||||
// Get the most recently created bot
|
||||
const sortedBots = [...data.bots].sort(
|
||||
(a: { createdAt: string }, b: { createdAt: string }) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const lastBot = sortedBots[0];
|
||||
|
||||
// Pre-fill LLM settings (but not API key for security)
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
llmProvider: lastBot.llmProvider || 'openai',
|
||||
llmModel: lastBot.llmModel || 'gpt-4',
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail - not critical
|
||||
console.error('Failed to fetch previous bot settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreviousBotSettings();
|
||||
}, []);
|
||||
|
||||
const handleAddSource = () => {
|
||||
// Validate based on type
|
||||
if (newSource.type === 'brave_news') {
|
||||
if (!newSource.braveQuery || !newSource.apiKey) return;
|
||||
// Build URL from config
|
||||
const url = new URL('https://api.search.brave.com/res/v1/news/search');
|
||||
url.searchParams.set('q', newSource.braveQuery);
|
||||
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
|
||||
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
|
||||
setSources([...sources, { ...newSource, url: url.toString() }]);
|
||||
} else if (newSource.type === 'news_api') {
|
||||
if (!newSource.newsQuery || !newSource.apiKey) return;
|
||||
// Build URL from config
|
||||
let baseUrl: string;
|
||||
const params = new URLSearchParams();
|
||||
switch (newSource.newsProvider) {
|
||||
case 'gnews':
|
||||
baseUrl = 'https://gnews.io/api/v4/search';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
||||
break;
|
||||
case 'newsdata':
|
||||
baseUrl = 'https://newsdata.io/api/1/news';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
||||
break;
|
||||
default:
|
||||
baseUrl = 'https://newsapi.org/v2/everything';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
}
|
||||
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
|
||||
} else {
|
||||
if (!newSource.url) return;
|
||||
setSources([...sources, { ...newSource }]);
|
||||
}
|
||||
setNewSource({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
braveQuery: '',
|
||||
braveFreshness: 'pw',
|
||||
newsProvider: 'newsapi',
|
||||
newsQuery: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveSource = (index: number) => {
|
||||
setSources(sources.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Calculate interval minutes based on frequency
|
||||
let intervalMinutes = 60;
|
||||
if (formData.postingFrequency === 'hourly') intervalMinutes = 60;
|
||||
else if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
||||
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
|
||||
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
|
||||
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
|
||||
else if (formData.postingFrequency === 'custom') intervalMinutes = formData.customIntervalMinutes;
|
||||
|
||||
const response = await fetch('/api/bots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
handle: formData.handle,
|
||||
bio: formData.bio,
|
||||
avatarUrl: formData.avatarUrl || undefined,
|
||||
headerUrl: formData.headerUrl || undefined,
|
||||
personality: {
|
||||
systemPrompt: formData.systemPrompt,
|
||||
temperature: 0.7, // Default value since posts are limited to 400 chars
|
||||
maxTokens: 500, // Default value, sufficient for 400 char posts
|
||||
},
|
||||
llmProvider: formData.llmProvider,
|
||||
llmModel: formData.llmModel,
|
||||
llmApiKey: formData.llmApiKey,
|
||||
autonomousMode: formData.autonomousMode,
|
||||
schedule: formData.autonomousMode ? {
|
||||
type: 'interval',
|
||||
intervalMinutes,
|
||||
} : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// If sources were added, create them after bot creation
|
||||
if (sources.length > 0) {
|
||||
for (const source of sources) {
|
||||
const sourcePayload: Record<string, unknown> = {
|
||||
type: source.type,
|
||||
url: source.url,
|
||||
subreddit: source.subreddit,
|
||||
apiKey: source.apiKey,
|
||||
};
|
||||
|
||||
// Add config for brave_news
|
||||
if (source.type === 'brave_news' && source.braveQuery) {
|
||||
sourcePayload.braveNewsConfig = {
|
||||
query: source.braveQuery,
|
||||
freshness: source.braveFreshness,
|
||||
country: source.braveCountry,
|
||||
};
|
||||
}
|
||||
|
||||
// Add config for news_api
|
||||
if (source.type === 'news_api' && source.newsQuery) {
|
||||
sourcePayload.newsApiConfig = {
|
||||
provider: source.newsProvider,
|
||||
query: source.newsQuery,
|
||||
category: source.newsCategory,
|
||||
country: source.newsCountry,
|
||||
language: source.newsLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
await fetch(`/api/bots/${data.bot.id}/sources`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sourcePayload),
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger the first post to establish a reference point for the schedule
|
||||
// This runs in the background - we don't wait for it
|
||||
fetch(`/api/bots/${data.bot.id}/post`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
}).catch(() => {
|
||||
// Silently fail - the bot will post on the next scheduled run
|
||||
});
|
||||
}
|
||||
|
||||
router.push(`/settings/bots/${data.bot.id}`);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
console.error('Bot creation failed:', data);
|
||||
const errorMsg = data.error || 'Failed to create bot';
|
||||
const detailsMsg = data.details ? '\n' + JSON.stringify(data.details, null, 2) : '';
|
||||
setError(errorMsg + detailsMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create bot error:', err);
|
||||
setError('Failed to create bot');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 'identity':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Bot Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input"
|
||||
placeholder="My Awesome Bot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Handle
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.handle}
|
||||
onChange={(e) => setFormData({ ...formData, handle: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })}
|
||||
className="input"
|
||||
placeholder="mybot"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Lowercase letters, numbers, and underscores only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.bio}
|
||||
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
|
||||
className="input"
|
||||
rows={3}
|
||||
placeholder="A brief description of what your bot does..."
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
{formData.bio.length}/400 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Avatar
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: uploadData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Avatar upload failed:', err);
|
||||
setError('Avatar upload failed');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}}
|
||||
disabled={uploadingAvatar}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{formData.avatarUrl && (
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
{formData.avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Square image recommended (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Banner
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{uploadingBanner ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingBanner(true);
|
||||
try {
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: uploadData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, headerUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Banner upload failed:', err);
|
||||
setError('Banner upload failed');
|
||||
} finally {
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
}}
|
||||
disabled={uploadingBanner}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{formData.headerUrl && (
|
||||
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
{formData.headerUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Wide image recommended, e.g. 1500x500 (optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'personality':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.systemPrompt}
|
||||
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
|
||||
className="input"
|
||||
rows={6}
|
||||
placeholder="You are a helpful bot that shares interesting tech news and engages with users about technology..."
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Define your bot's personality, tone, and behavior
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
LLM Provider
|
||||
</label>
|
||||
<select
|
||||
value={formData.llmProvider}
|
||||
onChange={(e) => setFormData({ ...formData, llmProvider: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.llmModel}
|
||||
onChange={(e) => setFormData({ ...formData, llmModel: e.target.value })}
|
||||
className="input"
|
||||
placeholder="gpt-4"
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
e.g., gpt-4, claude-3-opus, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.llmApiKey}
|
||||
onChange={(e) => setFormData({ ...formData, llmApiKey: e.target.value })}
|
||||
className="input"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||
Your API key is encrypted and stored securely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'sources':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<p style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
|
||||
Add content sources for your bot to pull from (optional)
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ padding: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Source Type
|
||||
</label>
|
||||
<select
|
||||
value={newSource.type}
|
||||
onChange={(e) => setNewSource({ ...newSource, type: e.target.value as ContentSource['type'] })}
|
||||
className="input"
|
||||
>
|
||||
<option value="rss">RSS Feed</option>
|
||||
<option value="reddit">Reddit</option>
|
||||
<option value="youtube">YouTube Channel/Playlist</option>
|
||||
<option value="brave_news">Brave News Search</option>
|
||||
<option value="news_api">News API (Advanced)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{newSource.type === 'rss' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
RSS Feed URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newSource.url}
|
||||
onChange={(e) => setNewSource({ ...newSource, url: e.target.value })}
|
||||
className="input"
|
||||
placeholder="https://example.com/feed.xml"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSource.type === 'youtube' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Channel ID or Playlist ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.youtubeChannelId || newSource.youtubePlaylistId || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
// Detect if it's a playlist ID (starts with PL) or channel ID (starts with UC)
|
||||
if (value.startsWith('PL')) {
|
||||
const rssUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${value}`;
|
||||
setNewSource({ ...newSource, youtubePlaylistId: value, youtubeChannelId: '', url: rssUrl });
|
||||
} else {
|
||||
const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${value}`;
|
||||
setNewSource({ ...newSource, youtubeChannelId: value, youtubePlaylistId: '', url: rssUrl });
|
||||
}
|
||||
}}
|
||||
className="input"
|
||||
placeholder="UCxxxx... or PLxxxx..."
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Channel IDs start with UC, Playlist IDs start with PL. Find in the YouTube URL.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{newSource.type === 'reddit' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Subreddit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.subreddit || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, subreddit: e.target.value, url: `https://reddit.com/r/${e.target.value}` })}
|
||||
className="input"
|
||||
placeholder="technology"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{newSource.type === 'brave_news' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Search Query
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.braveQuery || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, braveQuery: e.target.value })}
|
||||
className="input"
|
||||
placeholder="AI technology, climate change, etc."
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Use quotes for exact phrases, minus to exclude terms
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Freshness
|
||||
</label>
|
||||
<select
|
||||
value={newSource.braveFreshness || 'pw'}
|
||||
onChange={(e) => setNewSource({ ...newSource, braveFreshness: e.target.value as ContentSource['braveFreshness'] })}
|
||||
className="input"
|
||||
>
|
||||
<option value="pd">Last 24 hours</option>
|
||||
<option value="pw">Last 7 days</option>
|
||||
<option value="pm">Last 31 days</option>
|
||||
<option value="py">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Country (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newSource.braveCountry || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, braveCountry: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All countries</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="JP">Japan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Brave API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newSource.apiKey || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Your Brave Search API key"
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Get your API key at <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>brave.com/search/api</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{newSource.type === 'news_api' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
News Provider
|
||||
</label>
|
||||
<select
|
||||
value={newSource.newsProvider || 'newsapi'}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsProvider: e.target.value as ContentSource['newsProvider'] })}
|
||||
className="input"
|
||||
>
|
||||
<option value="newsapi">NewsAPI.org</option>
|
||||
<option value="gnews">GNews.io</option>
|
||||
<option value="newsdata">NewsData.io</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Search Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.newsQuery || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsQuery: e.target.value })}
|
||||
className="input"
|
||||
placeholder="technology, AI, startups"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Category (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newSource.newsCategory || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsCategory: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
<option value="technology">Technology</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="science">Science</option>
|
||||
<option value="health">Health</option>
|
||||
<option value="sports">Sports</option>
|
||||
<option value="entertainment">Entertainment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
Country (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newSource.newsCountry || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, newsCountry: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All countries</option>
|
||||
<option value="us">United States</option>
|
||||
<option value="gb">United Kingdom</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="de">Germany</option>
|
||||
<option value="fr">France</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newSource.apiKey || ''}
|
||||
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Your API key"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSource}
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
(newSource.type === 'rss' && !newSource.url) ||
|
||||
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
||||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
||||
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
|
||||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
||||
}
|
||||
>
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, marginBottom: '8px' }}>
|
||||
Added Sources ({sources.length})
|
||||
</h3>
|
||||
{sources.map((source, index) => (
|
||||
<div key={index} className="card" style={{ padding: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500 }}>
|
||||
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
||||
source.type === 'youtube' ? 'YOUTUBE' :
|
||||
source.type.toUpperCase()} - {
|
||||
source.type === 'brave_news' ? source.braveQuery :
|
||||
source.type === 'news_api' ? source.newsQuery :
|
||||
source.type === 'youtube' ? (source.youtubeChannelId || source.youtubePlaylistId) :
|
||||
source.subreddit || new URL(source.url).hostname
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSource(index)}
|
||||
className="btn btn-sm"
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'schedule':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.autonomousMode}
|
||||
onChange={(e) => setFormData({ ...formData, autonomousMode: e.target.checked })}
|
||||
style={{ width: '18px', height: '18px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', fontWeight: 500 }}>
|
||||
Enable Autonomous Mode
|
||||
</span>
|
||||
</label>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginLeft: '26px' }}>
|
||||
Bot will automatically post based on the schedule below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Posting Frequency
|
||||
</label>
|
||||
<select
|
||||
value={formData.postingFrequency}
|
||||
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
||||
className="input"
|
||||
disabled={!formData.autonomousMode}
|
||||
>
|
||||
<option value="hourly">Every Hour</option>
|
||||
<option value="every_2_hours">Every 2 Hours</option>
|
||||
<option value="every_4_hours">Every 4 Hours</option>
|
||||
<option value="every_6_hours">Every 6 Hours</option>
|
||||
<option value="daily">Once Daily</option>
|
||||
<option value="custom">Custom Interval</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.postingFrequency === 'custom' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Custom Interval (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.customIntervalMinutes}
|
||||
onChange={(e) => setFormData({ ...formData, customIntervalMinutes: parseInt(e.target.value) })}
|
||||
className="input"
|
||||
min="15"
|
||||
placeholder="60"
|
||||
disabled={!formData.autonomousMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ padding: '16px', background: 'var(--background-tertiary)' }}>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
|
||||
{formData.autonomousMode
|
||||
? `Your bot will automatically post ${formData.postingFrequency === 'custom' ? `every ${formData.customIntervalMinutes} minutes` : formData.postingFrequency.replace('_', ' ')}.`
|
||||
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = ['identity', 'personality', 'sources', 'schedule'] as const;
|
||||
const currentStepIndex = steps.indexOf(step);
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
|
||||
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Bot size={24} />
|
||||
Create New Bot
|
||||
</h1>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
Step {currentStepIndex + 1} of {steps.length}: {step.charAt(0).toUpperCase() + step.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="card" style={{ padding: '16px', marginBottom: '24px', borderColor: 'var(--error)', background: 'rgba(239, 68, 68, 0.1)' }}>
|
||||
<p style={{ color: 'var(--error)', fontSize: '14px', whiteSpace: 'pre-wrap', fontFamily: error.includes('{') ? 'monospace' : 'inherit' }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: i <= currentStepIndex ? 'var(--accent)' : 'var(--border)',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} onKeyDown={(e) => {
|
||||
// Prevent Enter key from submitting the form except on the last step
|
||||
if (e.key === 'Enter' && !isLastStep) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}>
|
||||
<div className="card" style={{ padding: '24px', marginBottom: '24px' }}>
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (currentStepIndex > 0) {
|
||||
setStep(steps[currentStepIndex - 1]);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
{currentStepIndex === 0 ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
|
||||
{!isLastStep ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setStep(steps[currentStepIndex + 1]);
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
(step === 'identity' && (!formData.name || !formData.handle)) ||
|
||||
(step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey))
|
||||
}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Bot'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Bot Management Page
|
||||
*
|
||||
* Lists user's bots and provides creation interface.
|
||||
*
|
||||
* Requirements: 1.3
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { Bot, Plus, Sparkles } from 'lucide-react';
|
||||
|
||||
interface BotData {
|
||||
id: string;
|
||||
name: string;
|
||||
handle: string;
|
||||
bio: string;
|
||||
avatarUrl: string | null;
|
||||
isActive: boolean;
|
||||
isSuspended: boolean;
|
||||
autonomousMode: boolean;
|
||||
lastPostAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export default function BotsPage() {
|
||||
const router = useRouter();
|
||||
const [bots, setBots] = useState<BotData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBots();
|
||||
}, []);
|
||||
|
||||
const fetchBots = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/bots');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBots(data.bots || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bots:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
|
||||
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Bot size={24} />
|
||||
My Bots
|
||||
</h1>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
Create and manage your automated bots
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/settings/bots/new" className="btn btn-primary">
|
||||
<Plus size={18} />
|
||||
Create Bot
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{bots.length === 0 ? (
|
||||
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
|
||||
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
|
||||
No bots yet
|
||||
</h2>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
|
||||
Create your first bot to start automating posts and interactions
|
||||
</p>
|
||||
<Link href="/settings/bots/new" className="btn btn-primary">
|
||||
<Plus size={18} />
|
||||
Create Your First Bot
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{bots.map((bot) => (
|
||||
<div
|
||||
key={bot.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}
|
||||
onClick={() => router.push(`/settings/bots/${bot.id}`)}
|
||||
>
|
||||
<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}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="avatar"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
flexShrink: 0,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
{bot.avatarUrl ? (
|
||||
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
|
||||
) : (
|
||||
bot.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</Link>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
|
||||
{bot.autonomousMode && (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '11px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
}}>
|
||||
<Sparkles size={12} />
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${bot.handle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
|
||||
>
|
||||
@{bot.handle}
|
||||
</Link>
|
||||
{bot.bio && (
|
||||
<p style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--foreground-secondary)',
|
||||
marginTop: '8px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}>
|
||||
{bot.bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||
{bot.isSuspended ? (
|
||||
<span className="status-pill suspended">
|
||||
Suspended
|
||||
</span>
|
||||
) : bot.isActive ? (
|
||||
<span className="status-pill active">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="status-pill">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}>
|
||||
<span>
|
||||
Last post: {bot.lastPostAt
|
||||
? new Date(bot.lastPostAt).toLocaleDateString()
|
||||
: 'Never'}
|
||||
</span>
|
||||
<span>
|
||||
Created: {new Date(bot.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user