feat(auth): Integrate Cloudflare Turnstile bot protection and enhance swarm tracking
- Add Cloudflare Turnstile integration for login and registration forms - Create turnstile verification utility with server-side token validation - Add turnstile_site_key and turnstile_secret_key fields to nodes table - Implement admin panel UI for configuring Turnstile keys in settings - Update login and register API routes to verify Turnstile tokens - Expose turnstile_site_key via GET /api/node endpoint for frontend - Add admin endpoint to save and update Turnstile configuration - Create remote_likes and remote_reposts tables for federated interaction tracking - Add swarm reply metadata fields (swarm_reply_to_id, swarm_reply_to_content, swarm_reply_to_author) to posts table - Add comprehensive TURNSTILE_SETUP.md documentation with setup instructions and security notes - Create database migration 0007 with schema updates and indexes - Protects against bot registrations and automated attacks on federated nodes
This commit is contained in:
@@ -72,6 +72,8 @@ export default function AdminPage() {
|
||||
faviconUrl: '',
|
||||
accentColor: '#FFFFFF',
|
||||
isNsfw: false,
|
||||
turnstileSiteKey: '',
|
||||
turnstileSecretKey: '',
|
||||
});
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||
@@ -142,6 +144,8 @@ export default function AdminPage() {
|
||||
faviconUrl: data.faviconUrl || '',
|
||||
accentColor: data.accentColor || '#FFFFFF',
|
||||
isNsfw: data.isNsfw || false,
|
||||
turnstileSiteKey: data.turnstileSiteKey || '',
|
||||
turnstileSecretKey: data.turnstileSecretKey || '',
|
||||
});
|
||||
} catch {
|
||||
// error
|
||||
@@ -809,6 +813,71 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', display: 'block' }}>
|
||||
Cloudflare Turnstile (Bot Protection)
|
||||
</label>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-secondary)', marginBottom: '12px' }}>
|
||||
Add Cloudflare Turnstile to protect registration and login from bots. Get your keys from the{' '}
|
||||
<a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>
|
||||
Cloudflare Dashboard
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>
|
||||
Site Key
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={nodeSettings.turnstileSiteKey}
|
||||
onChange={e => setNodeSettings({ ...nodeSettings, turnstileSiteKey: e.target.value })}
|
||||
placeholder="0x4AAAAAAA..."
|
||||
style={{ fontFamily: 'monospace', fontSize: '13px' }}
|
||||
/>
|
||||
<p style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Public key shown to users
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>
|
||||
Secret Key
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={nodeSettings.turnstileSecretKey}
|
||||
onChange={e => setNodeSettings({ ...nodeSettings, turnstileSecretKey: e.target.value })}
|
||||
placeholder="0x4AAAAAAA..."
|
||||
style={{ fontFamily: 'monospace', fontSize: '13px' }}
|
||||
/>
|
||||
<p style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Secret key for server-side verification
|
||||
</p>
|
||||
</div>
|
||||
{nodeSettings.turnstileSiteKey && nodeSettings.turnstileSecretKey && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: 'rgb(34, 197, 94)',
|
||||
}}>
|
||||
✓ Turnstile is enabled and will be shown on login/registration
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
||||
{savingSettings ? 'Saving...' : 'Save Settings'}
|
||||
|
||||
@@ -27,6 +27,8 @@ export async function PATCH(req: NextRequest) {
|
||||
faviconUrl: data.faviconUrl,
|
||||
accentColor: data.accentColor,
|
||||
isNsfw: data.isNsfw ?? false,
|
||||
turnstileSiteKey: data.turnstileSiteKey,
|
||||
turnstileSecretKey: data.turnstileSecretKey,
|
||||
}).returning();
|
||||
} else {
|
||||
[node] = await db.update(nodes)
|
||||
@@ -40,6 +42,8 @@ export async function PATCH(req: NextRequest) {
|
||||
faviconUrl: data.faviconUrl,
|
||||
accentColor: data.accentColor,
|
||||
isNsfw: data.isNsfw ?? node.isNsfw,
|
||||
turnstileSiteKey: data.turnstileSiteKey !== undefined ? data.turnstileSiteKey : node.turnstileSiteKey,
|
||||
turnstileSecretKey: data.turnstileSecretKey !== undefined ? data.turnstileSecretKey : node.turnstileSecretKey,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nodes.id, node.id))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authenticateUser, createSession } from '@/lib/auth';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
turnstileToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -12,6 +14,18 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const data = loginSchema.parse(body);
|
||||
|
||||
// Verify Turnstile token if provided
|
||||
if (data.turnstileToken) {
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
|
||||
const isValid = await verifyTurnstileToken(data.turnstileToken, ip);
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot verification failed. Please try again.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await authenticateUser(data.email, data.password);
|
||||
|
||||
// Create session
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { registerUser, createSession } from '@/lib/auth';
|
||||
import { db, nodes, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
@@ -9,6 +10,7 @@ const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().optional(),
|
||||
turnstileToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -16,6 +18,18 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const data = registerSchema.parse(body);
|
||||
|
||||
// Verify Turnstile token if provided
|
||||
if (data.turnstileToken) {
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
|
||||
const isValid = await verifyTurnstileToken(data.turnstileToken, ip);
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bot verification failed. Please try again.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await registerUser(
|
||||
data.handle,
|
||||
data.email,
|
||||
|
||||
@@ -38,10 +38,16 @@ export async function GET() {
|
||||
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#FFFFFF',
|
||||
domain,
|
||||
admins,
|
||||
turnstileSiteKey: null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...node, admins });
|
||||
return NextResponse.json({
|
||||
...node,
|
||||
admins,
|
||||
// Don't expose the secret key
|
||||
turnstileSecretKey: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Node info error:', error);
|
||||
return NextResponse.json({
|
||||
|
||||
+99
-6
@@ -1,10 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (element: string | HTMLElement, options: {
|
||||
sitekey: string;
|
||||
callback?: (token: string) => void;
|
||||
'error-callback'?: () => void;
|
||||
'expired-callback'?: () => void;
|
||||
}) => string;
|
||||
reset: (widgetId: string) => void;
|
||||
remove: (widgetId: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -15,9 +30,13 @@ export default function LoginPage() {
|
||||
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 [nodeInfo, setNodeInfo] = useState<{ name: string; description: string; logoUrl?: string; isNsfw?: boolean; turnstileSiteKey?: string | null }>({ name: '', description: '' });
|
||||
const [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
|
||||
const [ageVerified, setAgeVerified] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
const [turnstileLoaded, setTurnstileLoaded] = useState(false);
|
||||
const turnstileRef = useRef<HTMLDivElement>(null);
|
||||
const turnstileWidgetId = useRef<string | null>(null);
|
||||
|
||||
// Import specific state
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
@@ -36,7 +55,8 @@ export default function LoginPage() {
|
||||
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
|
||||
isNsfw: data.isNsfw || false,
|
||||
turnstileSiteKey: data.turnstileSiteKey || null,
|
||||
});
|
||||
// Update page title
|
||||
if (data.name && data.name !== 'Synapsis') {
|
||||
@@ -49,6 +69,62 @@ export default function LoginPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load Turnstile script if site key is available
|
||||
useEffect(() => {
|
||||
if (!nodeInfo.turnstileSiteKey) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setTurnstileLoaded(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(script);
|
||||
};
|
||||
}, [nodeInfo.turnstileSiteKey]);
|
||||
|
||||
// Render Turnstile widget when ready
|
||||
useEffect(() => {
|
||||
if (!turnstileLoaded || !nodeInfo.turnstileSiteKey || !turnstileRef.current || mode === 'import') return;
|
||||
|
||||
// Clean up previous widget
|
||||
if (turnstileWidgetId.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(turnstileWidgetId.current);
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Render new widget
|
||||
if (window.turnstile) {
|
||||
turnstileWidgetId.current = window.turnstile.render(turnstileRef.current, {
|
||||
sitekey: nodeInfo.turnstileSiteKey,
|
||||
callback: (token: string) => {
|
||||
setTurnstileToken(token);
|
||||
},
|
||||
'error-callback': () => {
|
||||
setTurnstileToken(null);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
setTurnstileToken(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (turnstileWidgetId.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(turnstileWidgetId.current);
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [turnstileLoaded, nodeInfo.turnstileSiteKey, mode]);
|
||||
|
||||
// Handle availability check
|
||||
useEffect(() => {
|
||||
const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : '');
|
||||
@@ -139,13 +215,19 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Turnstile is required but not completed
|
||||
if (nodeInfo.turnstileSiteKey && !turnstileToken) {
|
||||
setError('Please complete the verification challenge');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||
const body = mode === 'login'
|
||||
? { email, password }
|
||||
: { email, password, handle, displayName };
|
||||
? { email, password, turnstileToken }
|
||||
: { email, password, handle, displayName, turnstileToken };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
@@ -163,6 +245,11 @@ export default function LoginPage() {
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
// Reset Turnstile on error
|
||||
if (turnstileWidgetId.current && window.turnstile) {
|
||||
window.turnstile.reset(turnstileWidgetId.current);
|
||||
setTurnstileToken(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -421,11 +508,17 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeInfo.turnstileSiteKey && (
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div ref={turnstileRef}></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loading}
|
||||
disabled={loading || (nodeInfo.turnstileSiteKey && !turnstileToken)}
|
||||
>
|
||||
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
||||
</button>
|
||||
|
||||
+22
-47
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { PostCard } from '@/components/PostCard';
|
||||
import { Compose } from '@/components/Compose';
|
||||
@@ -19,6 +20,7 @@ interface FeedMeta {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -26,8 +28,6 @@ export default function Home() {
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
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;
|
||||
@@ -42,18 +42,12 @@ export default function Home() {
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch node info to check if NSFW
|
||||
// Redirect unauthenticated users to explore page
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setIsNsfwNode(data.isNsfw || false);
|
||||
setNodeInfoLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setNodeInfoLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
if (user === null) {
|
||||
router.push('/explore');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const loadFeed = async (type: 'latest' | 'curated', cursor?: string | null) => {
|
||||
if (cursor) {
|
||||
@@ -157,6 +151,15 @@ export default function Home() {
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
};
|
||||
|
||||
// Show loading while checking auth
|
||||
if (user === null) {
|
||||
return (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header style={{
|
||||
@@ -187,24 +190,11 @@ export default function Home() {
|
||||
</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>
|
||||
)}
|
||||
<Compose
|
||||
onPost={handlePost}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
/>
|
||||
|
||||
{feedType === 'curated' && feedMeta && (
|
||||
<div className="feed-meta card">
|
||||
@@ -215,22 +205,7 @@ export default function Home() {
|
||||
</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 ? (
|
||||
{loading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
@@ -41,14 +41,14 @@ export default function EditBotPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const botId = params.id as string;
|
||||
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = 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: '',
|
||||
@@ -60,8 +60,9 @@ export default function EditBotPage() {
|
||||
llmModel: 'gpt-4',
|
||||
llmApiKey: '',
|
||||
autonomousMode: false,
|
||||
postingFrequency: 'hourly',
|
||||
customIntervalMinutes: 60,
|
||||
|
||||
postingFrequency: 'every_4_hours',
|
||||
customIntervalMinutes: 240,
|
||||
});
|
||||
|
||||
const [sources, setSources] = useState<ContentSource[]>([]);
|
||||
@@ -95,28 +96,28 @@ export default function EditBotPage() {
|
||||
|
||||
const botData = await botRes.json();
|
||||
const bot = botData.bot;
|
||||
|
||||
|
||||
const personalityConfig = typeof bot.personalityConfig === 'string'
|
||||
? JSON.parse(bot.personalityConfig)
|
||||
: bot.personalityConfig || {};
|
||||
|
||||
|
||||
const scheduleConfig = typeof bot.scheduleConfig === 'string'
|
||||
? JSON.parse(bot.scheduleConfig)
|
||||
: bot.scheduleConfig || {};
|
||||
|
||||
// Determine posting frequency from interval
|
||||
let postingFrequency = 'hourly';
|
||||
let customIntervalMinutes = 60;
|
||||
let postingFrequency = 'every_4_hours';
|
||||
let customIntervalMinutes = 240;
|
||||
if (scheduleConfig?.intervalMinutes) {
|
||||
const interval = scheduleConfig.intervalMinutes;
|
||||
if (interval === 60) postingFrequency = 'hourly';
|
||||
else if (interval === 120) postingFrequency = 'every_2_hours';
|
||||
if (interval === 120) postingFrequency = 'every_2_hours';
|
||||
else if (interval === 240) postingFrequency = 'every_4_hours';
|
||||
else if (interval === 360) postingFrequency = 'every_6_hours';
|
||||
else if (interval === 1440) postingFrequency = 'daily';
|
||||
else {
|
||||
postingFrequency = 'custom';
|
||||
customIntervalMinutes = interval;
|
||||
// If it was hourly (60) or custom, default to every_4_hours (240)
|
||||
postingFrequency = 'every_4_hours';
|
||||
customIntervalMinutes = 240;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +188,9 @@ export default function EditBotPage() {
|
||||
if (!newSource.url) return;
|
||||
setSources([...sources, { ...newSource }]);
|
||||
}
|
||||
setNewSource({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
setNewSource({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
fetchIntervalMinutes: 30,
|
||||
braveQuery: '',
|
||||
braveFreshness: 'pw',
|
||||
@@ -213,13 +214,11 @@ export default function EditBotPage() {
|
||||
|
||||
try {
|
||||
// Calculate interval minutes
|
||||
let intervalMinutes = 60;
|
||||
if (formData.postingFrequency === 'hourly') intervalMinutes = 60;
|
||||
else if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
||||
let intervalMinutes = 240;
|
||||
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;
|
||||
|
||||
// Update bot
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
@@ -271,7 +270,7 @@ export default function EditBotPage() {
|
||||
subreddit: source.subreddit,
|
||||
apiKey: source.apiKey,
|
||||
};
|
||||
|
||||
|
||||
// Add config for brave_news
|
||||
if (source.type === 'brave_news' && source.braveQuery) {
|
||||
sourcePayload.braveNewsConfig = {
|
||||
@@ -280,7 +279,7 @@ export default function EditBotPage() {
|
||||
country: source.braveCountry,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Add config for news_api
|
||||
if (source.type === 'news_api' && source.newsQuery) {
|
||||
sourcePayload.newsApiConfig = {
|
||||
@@ -291,7 +290,7 @@ export default function EditBotPage() {
|
||||
language: source.newsLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
await fetch(`/api/bots/${botId}/sources`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -826,14 +825,14 @@ export default function EditBotPage() {
|
||||
} catch {
|
||||
displayName = source.url || 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div key={source.id || 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()} - {displayName}
|
||||
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
||||
source.type === 'youtube' ? 'YOUTUBE' :
|
||||
source.type.toUpperCase()} - {displayName}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
{source.id ? 'Existing source' : 'New source (will be added)'}
|
||||
@@ -886,36 +885,19 @@ export default function EditBotPage() {
|
||||
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('_', ' ')}.`
|
||||
{formData.autonomousMode
|
||||
? `Your bot will automatically post ${formData.postingFrequency.replace('_', ' ')}.`
|
||||
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function NewBotPage() {
|
||||
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: '',
|
||||
@@ -53,8 +53,8 @@ export default function NewBotPage() {
|
||||
llmModel: 'gpt-4',
|
||||
llmApiKey: '',
|
||||
autonomousMode: false,
|
||||
postingFrequency: 'hourly',
|
||||
customIntervalMinutes: 60,
|
||||
postingFrequency: 'every_4_hours',
|
||||
customIntervalMinutes: 240,
|
||||
});
|
||||
|
||||
const [sources, setSources] = useState<ContentSource[]>([]);
|
||||
@@ -77,11 +77,11 @@ export default function NewBotPage() {
|
||||
if (data.bots && data.bots.length > 0) {
|
||||
// Get the most recently created bot
|
||||
const sortedBots = [...data.bots].sort(
|
||||
(a: { createdAt: string }, b: { createdAt: string }) =>
|
||||
(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,
|
||||
@@ -95,7 +95,7 @@ export default function NewBotPage() {
|
||||
console.error('Failed to fetch previous bot settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
fetchPreviousBotSettings();
|
||||
}, []);
|
||||
|
||||
@@ -139,9 +139,9 @@ export default function NewBotPage() {
|
||||
if (!newSource.url) return;
|
||||
setSources([...sources, { ...newSource }]);
|
||||
}
|
||||
setNewSource({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
setNewSource({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
braveQuery: '',
|
||||
braveFreshness: 'pw',
|
||||
newsProvider: 'newsapi',
|
||||
@@ -160,13 +160,11 @@ export default function NewBotPage() {
|
||||
|
||||
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;
|
||||
let intervalMinutes = 240;
|
||||
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',
|
||||
@@ -195,7 +193,7 @@ export default function NewBotPage() {
|
||||
|
||||
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) {
|
||||
@@ -205,7 +203,7 @@ export default function NewBotPage() {
|
||||
subreddit: source.subreddit,
|
||||
apiKey: source.apiKey,
|
||||
};
|
||||
|
||||
|
||||
// Add config for brave_news
|
||||
if (source.type === 'brave_news' && source.braveQuery) {
|
||||
sourcePayload.braveNewsConfig = {
|
||||
@@ -214,7 +212,7 @@ export default function NewBotPage() {
|
||||
country: source.braveCountry,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Add config for news_api
|
||||
if (source.type === 'news_api' && source.newsQuery) {
|
||||
sourcePayload.newsApiConfig = {
|
||||
@@ -225,14 +223,14 @@ export default function NewBotPage() {
|
||||
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`, {
|
||||
@@ -243,7 +241,7 @@ export default function NewBotPage() {
|
||||
// Silently fail - the bot will post on the next scheduled run
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
router.push(`/settings/bots/${data.bot.id}`);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
@@ -763,13 +761,13 @@ export default function NewBotPage() {
|
||||
<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' ? '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
|
||||
source.type === 'news_api' ? source.newsQuery :
|
||||
source.type === 'youtube' ? (source.youtubeChannelId || source.youtubePlaylistId) :
|
||||
source.subreddit || new URL(source.url).hostname
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -819,36 +817,19 @@ export default function NewBotPage() {
|
||||
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('_', ' ')}.`
|
||||
{formData.autonomousMode
|
||||
? `Your bot will automatically post ${formData.postingFrequency.replace('_', ' ')}.`
|
||||
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, isAdmin } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
@@ -47,9 +50,22 @@ export function Sidebar() {
|
||||
// Home is exact match
|
||||
const isHome = pathname === '/';
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loggingOut) return;
|
||||
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/explore';
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
setLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<Link href="/" className="logo" style={{ minHeight: '42px' }}>
|
||||
<Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}>
|
||||
{customLogoUrl === undefined ? null : customLogoUrl ? (
|
||||
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
|
||||
) : (
|
||||
@@ -57,10 +73,12 @@ export function Sidebar() {
|
||||
)}
|
||||
</Link>
|
||||
<nav>
|
||||
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
||||
<HomeIcon />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
{user && (
|
||||
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
||||
<HomeIcon />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`}>
|
||||
<SearchIcon />
|
||||
<span>Explore</span>
|
||||
@@ -114,7 +132,7 @@ export function Sidebar() {
|
||||
</nav>
|
||||
{user && (
|
||||
<div style={{ marginTop: 'auto', paddingTop: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}>
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
@@ -122,11 +140,26 @@ export function Sidebar() {
|
||||
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatFullHandle(user.handle)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
gap: '12px',
|
||||
padding: '10px 12px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>{loggingOut ? 'Signing out...' : 'Sign out'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -19,6 +19,9 @@ export const nodes = pgTable('nodes', {
|
||||
publicKey: text('public_key'),
|
||||
// NSFW settings
|
||||
isNsfw: boolean('is_nsfw').default(false).notNull(), // Entire node is NSFW
|
||||
// Cloudflare Turnstile settings
|
||||
turnstileSiteKey: text('turnstile_site_key'),
|
||||
turnstileSecretKey: text('turnstile_secret_key'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { db, nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function verifyTurnstileToken(token: string, ip?: string): Promise<boolean> {
|
||||
try {
|
||||
// Get node settings to check if Turnstile is enabled
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
// If no secret key is configured, skip verification (Turnstile is disabled)
|
||||
if (!node?.turnstileSecretKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verify the token with Cloudflare
|
||||
const formData = new FormData();
|
||||
formData.append('secret', node.turnstileSecretKey);
|
||||
formData.append('response', token);
|
||||
if (ip) {
|
||||
formData.append('remoteip', ip);
|
||||
}
|
||||
|
||||
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.success === true;
|
||||
} catch (error) {
|
||||
console.error('Turnstile verification error:', error);
|
||||
// On error, fail closed (reject the request)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTurnstileSiteKey(): Promise<string | null> {
|
||||
try {
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
return node?.turnstileSiteKey || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Turnstile site key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user