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:
Christomatt
2026-01-26 18:10:48 +01:00
parent cf0dfa4b66
commit 5b0269af34
16 changed files with 4403 additions and 153 deletions
+72
View File
@@ -0,0 +1,72 @@
# Cloudflare Turnstile Setup Guide
Cloudflare Turnstile has been fully integrated into your Synapsis node to protect against bot registrations and logins.
## How It Works
1. **Admin Configuration**: Admins can add their Cloudflare Turnstile keys in the Admin Settings panel
2. **Automatic Activation**: Once both Site Key and Secret Key are configured, Turnstile is automatically enabled
3. **Frontend Integration**: The Turnstile widget appears on login and registration forms
4. **Server Verification**: All login/register requests are verified server-side with Cloudflare
## Setup Steps
### 1. Get Turnstile Keys from Cloudflare
1. Go to https://dash.cloudflare.com/?to=/:account/turnstile
2. Create a new site
3. Copy your **Site Key** (public) and **Secret Key** (private)
### 2. Configure in Admin Panel
1. Log in as an admin
2. Go to Admin → Settings tab
3. Scroll to "Cloudflare Turnstile (Bot Protection)" section
4. Paste your Site Key and Secret Key
5. Click "Save Settings"
### 3. Test It
1. Log out
2. Go to the login page
3. You should see the Turnstile widget appear
4. Complete the challenge and try logging in
## Features
- ✅ Automatic widget rendering when keys are configured
- ✅ Works on both login and registration forms
- ✅ Server-side token verification
- ✅ Automatic widget reset on form errors
- ✅ Graceful fallback if Turnstile is not configured
- ✅ Submit button disabled until challenge is completed
- ✅ IP address forwarding for better verification
## Technical Details
### Database Schema
- `nodes.turnstile_site_key` - Public site key (exposed to frontend)
- `nodes.turnstile_secret_key` - Private secret key (server-side only)
### API Endpoints Modified
- `POST /api/auth/login` - Now accepts optional `turnstileToken`
- `POST /api/auth/register` - Now accepts optional `turnstileToken`
- `GET /api/node` - Returns `turnstileSiteKey` for frontend
### Files Modified
- `src/db/schema.ts` - Added Turnstile fields
- `src/lib/turnstile.ts` - Verification helper functions
- `src/app/api/auth/login/route.ts` - Token verification
- `src/app/api/auth/register/route.ts` - Token verification
- `src/app/api/node/route.ts` - Expose site key
- `src/app/api/admin/node/route.ts` - Save/update keys
- `src/app/admin/page.tsx` - Admin UI for configuration
- `src/app/login/page.tsx` - Frontend widget integration
## Security Notes
- The Secret Key is NEVER exposed to the frontend
- Only the Site Key is public
- Verification happens server-side with Cloudflare's API
- Failed verifications reject the login/registration attempt
- IP addresses are forwarded for better bot detection
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE "remote_likes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" uuid NOT NULL,
"actor_handle" text NOT NULL,
"actor_node_domain" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "remote_reposts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"post_id" uuid NOT NULL,
"actor_handle" text NOT NULL,
"actor_node_domain" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "nodes" ADD COLUMN "turnstile_site_key" text;--> statement-breakpoint
ALTER TABLE "nodes" ADD COLUMN "turnstile_secret_key" text;--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "swarm_reply_to_id" text;--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "swarm_reply_to_content" text;--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "swarm_reply_to_author" text;--> statement-breakpoint
ALTER TABLE "remote_likes" ADD CONSTRAINT "remote_likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "remote_reposts" ADD CONSTRAINT "remote_reposts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "remote_likes_post_idx" ON "remote_likes" USING btree ("post_id");--> statement-breakpoint
CREATE INDEX "remote_likes_actor_idx" ON "remote_likes" USING btree ("actor_handle","actor_node_domain");--> statement-breakpoint
CREATE UNIQUE INDEX "remote_likes_unique" ON "remote_likes" USING btree ("post_id","actor_handle","actor_node_domain");--> statement-breakpoint
CREATE INDEX "remote_reposts_post_idx" ON "remote_reposts" USING btree ("post_id");--> statement-breakpoint
CREATE INDEX "remote_reposts_actor_idx" ON "remote_reposts" USING btree ("actor_handle","actor_node_domain");--> statement-breakpoint
CREATE UNIQUE INDEX "remote_reposts_unique" ON "remote_reposts" USING btree ("post_id","actor_handle","actor_node_domain");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1769427398729,
"tag": "0006_loud_moonstone",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1769446756226,
"tag": "0007_kind_agent_zero",
"breakpoints": true
}
]
}
+69
View File
@@ -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'}
+4
View File
@@ -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))
+14
View File
@@ -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
+14
View File
@@ -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,
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+28 -46
View File
@@ -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>
+26 -45
View File
@@ -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>
+41 -8
View File
@@ -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>
+3
View File
@@ -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(),
});
+51
View File
@@ -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;
}
}