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:
@@ -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
|
||||||
@@ -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
@@ -50,6 +50,13 @@
|
|||||||
"when": 1769427398729,
|
"when": 1769427398729,
|
||||||
"tag": "0006_loud_moonstone",
|
"tag": "0006_loud_moonstone",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769446756226,
|
||||||
|
"tag": "0007_kind_agent_zero",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,8 @@ export default function AdminPage() {
|
|||||||
faviconUrl: '',
|
faviconUrl: '',
|
||||||
accentColor: '#FFFFFF',
|
accentColor: '#FFFFFF',
|
||||||
isNsfw: false,
|
isNsfw: false,
|
||||||
|
turnstileSiteKey: '',
|
||||||
|
turnstileSecretKey: '',
|
||||||
});
|
});
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||||
@@ -142,6 +144,8 @@ export default function AdminPage() {
|
|||||||
faviconUrl: data.faviconUrl || '',
|
faviconUrl: data.faviconUrl || '',
|
||||||
accentColor: data.accentColor || '#FFFFFF',
|
accentColor: data.accentColor || '#FFFFFF',
|
||||||
isNsfw: data.isNsfw || false,
|
isNsfw: data.isNsfw || false,
|
||||||
|
turnstileSiteKey: data.turnstileSiteKey || '',
|
||||||
|
turnstileSecretKey: data.turnstileSecretKey || '',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// error
|
// error
|
||||||
@@ -809,6 +813,71 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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' }}>
|
<div style={{ paddingTop: '8px' }}>
|
||||||
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
||||||
{savingSettings ? 'Saving...' : 'Save Settings'}
|
{savingSettings ? 'Saving...' : 'Save Settings'}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export async function PATCH(req: NextRequest) {
|
|||||||
faviconUrl: data.faviconUrl,
|
faviconUrl: data.faviconUrl,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
isNsfw: data.isNsfw ?? false,
|
isNsfw: data.isNsfw ?? false,
|
||||||
|
turnstileSiteKey: data.turnstileSiteKey,
|
||||||
|
turnstileSecretKey: data.turnstileSecretKey,
|
||||||
}).returning();
|
}).returning();
|
||||||
} else {
|
} else {
|
||||||
[node] = await db.update(nodes)
|
[node] = await db.update(nodes)
|
||||||
@@ -40,6 +42,8 @@ export async function PATCH(req: NextRequest) {
|
|||||||
faviconUrl: data.faviconUrl,
|
faviconUrl: data.faviconUrl,
|
||||||
accentColor: data.accentColor,
|
accentColor: data.accentColor,
|
||||||
isNsfw: data.isNsfw ?? node.isNsfw,
|
isNsfw: data.isNsfw ?? node.isNsfw,
|
||||||
|
turnstileSiteKey: data.turnstileSiteKey !== undefined ? data.turnstileSiteKey : node.turnstileSiteKey,
|
||||||
|
turnstileSecretKey: data.turnstileSecretKey !== undefined ? data.turnstileSecretKey : node.turnstileSecretKey,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(nodes.id, node.id))
|
.where(eq(nodes.id, node.id))
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { authenticateUser, createSession } from '@/lib/auth';
|
import { authenticateUser, createSession } from '@/lib/auth';
|
||||||
|
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
turnstileToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -12,6 +14,18 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const data = loginSchema.parse(body);
|
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);
|
const user = await authenticateUser(data.email, data.password);
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { registerUser, createSession } from '@/lib/auth';
|
import { registerUser, createSession } from '@/lib/auth';
|
||||||
import { db, nodes, users } from '@/db';
|
import { db, nodes, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -9,6 +10,7 @@ const registerSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
displayName: z.string().optional(),
|
displayName: z.string().optional(),
|
||||||
|
turnstileToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -16,6 +18,18 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const data = registerSchema.parse(body);
|
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(
|
const user = await registerUser(
|
||||||
data.handle,
|
data.handle,
|
||||||
data.email,
|
data.email,
|
||||||
|
|||||||
@@ -38,10 +38,16 @@ export async function GET() {
|
|||||||
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#FFFFFF',
|
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#FFFFFF',
|
||||||
domain,
|
domain,
|
||||||
admins,
|
admins,
|
||||||
|
turnstileSiteKey: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ...node, admins });
|
return NextResponse.json({
|
||||||
|
...node,
|
||||||
|
admins,
|
||||||
|
// Don't expose the secret key
|
||||||
|
turnstileSecretKey: undefined,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Node info error:', error);
|
console.error('Node info error:', error);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
+99
-6
@@ -1,10 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { TriangleAlert } from 'lucide-react';
|
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() {
|
export default function LoginPage() {
|
||||||
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -15,9 +30,13 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nodeInfoLoaded, setNodeInfoLoaded] = 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 [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
|
||||||
const [ageVerified, setAgeVerified] = useState(false);
|
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
|
// Import specific state
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
@@ -36,7 +55,8 @@ export default function LoginPage() {
|
|||||||
name: data.name || '',
|
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.',
|
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,
|
logoUrl: data.logoUrl || undefined,
|
||||||
isNsfw: data.isNsfw || false
|
isNsfw: data.isNsfw || false,
|
||||||
|
turnstileSiteKey: data.turnstileSiteKey || null,
|
||||||
});
|
});
|
||||||
// Update page title
|
// Update page title
|
||||||
if (data.name && data.name !== 'Synapsis') {
|
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
|
// Handle availability check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : '');
|
const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : '');
|
||||||
@@ -139,13 +215,19 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if Turnstile is required but not completed
|
||||||
|
if (nodeInfo.turnstileSiteKey && !turnstileToken) {
|
||||||
|
setError('Please complete the verification challenge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
const body = mode === 'login'
|
const body = mode === 'login'
|
||||||
? { email, password }
|
? { email, password, turnstileToken }
|
||||||
: { email, password, handle, displayName };
|
: { email, password, handle, displayName, turnstileToken };
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -163,6 +245,11 @@ export default function LoginPage() {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -421,11 +508,17 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{nodeInfo.turnstileSiteKey && (
|
||||||
|
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<div ref={turnstileRef}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary btn-lg"
|
className="btn btn-primary btn-lg"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
disabled={loading}
|
disabled={loading || (nodeInfo.turnstileSiteKey && !turnstileToken)}
|
||||||
>
|
>
|
||||||
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+22
-47
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { Compose } from '@/components/Compose';
|
import { Compose } from '@/components/Compose';
|
||||||
@@ -19,6 +20,7 @@ interface FeedMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -26,8 +28,6 @@ export default function Home() {
|
|||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
|
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
|
||||||
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
||||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
|
||||||
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
|
|
||||||
const [feedMeta, setFeedMeta] = useState<{
|
const [feedMeta, setFeedMeta] = useState<{
|
||||||
algorithm: string;
|
algorithm: string;
|
||||||
windowHours: number;
|
windowHours: number;
|
||||||
@@ -42,18 +42,12 @@ export default function Home() {
|
|||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Fetch node info to check if NSFW
|
// Redirect unauthenticated users to explore page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node')
|
if (user === null) {
|
||||||
.then(res => res.json())
|
router.push('/explore');
|
||||||
.then(data => {
|
}
|
||||||
setIsNsfwNode(data.isNsfw || false);
|
}, [user, router]);
|
||||||
setNodeInfoLoaded(true);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setNodeInfoLoaded(true);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadFeed = async (type: 'latest' | 'curated', cursor?: string | null) => {
|
const loadFeed = async (type: 'latest' | 'curated', cursor?: string | null) => {
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@@ -157,6 +151,15 @@ export default function Home() {
|
|||||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header style={{
|
<header style={{
|
||||||
@@ -187,24 +190,11 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{user && (
|
<Compose
|
||||||
<Compose
|
onPost={handlePost}
|
||||||
onPost={handlePost}
|
replyingTo={replyingTo}
|
||||||
replyingTo={replyingTo}
|
onCancelReply={() => setReplyingTo(null)}
|
||||||
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 && (
|
{feedType === 'curated' && feedMeta && (
|
||||||
<div className="feed-meta card">
|
<div className="feed-meta card">
|
||||||
@@ -215,22 +205,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* NSFW node gate for unauthenticated users */}
|
{loading ? (
|
||||||
{!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)' }}>
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export default function EditBotPage() {
|
|||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
llmApiKey: '',
|
llmApiKey: '',
|
||||||
autonomousMode: false,
|
autonomousMode: false,
|
||||||
postingFrequency: 'hourly',
|
|
||||||
customIntervalMinutes: 60,
|
postingFrequency: 'every_4_hours',
|
||||||
|
customIntervalMinutes: 240,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sources, setSources] = useState<ContentSource[]>([]);
|
const [sources, setSources] = useState<ContentSource[]>([]);
|
||||||
@@ -105,18 +106,18 @@ export default function EditBotPage() {
|
|||||||
: bot.scheduleConfig || {};
|
: bot.scheduleConfig || {};
|
||||||
|
|
||||||
// Determine posting frequency from interval
|
// Determine posting frequency from interval
|
||||||
let postingFrequency = 'hourly';
|
let postingFrequency = 'every_4_hours';
|
||||||
let customIntervalMinutes = 60;
|
let customIntervalMinutes = 240;
|
||||||
if (scheduleConfig?.intervalMinutes) {
|
if (scheduleConfig?.intervalMinutes) {
|
||||||
const interval = scheduleConfig.intervalMinutes;
|
const interval = scheduleConfig.intervalMinutes;
|
||||||
if (interval === 60) postingFrequency = 'hourly';
|
if (interval === 120) postingFrequency = 'every_2_hours';
|
||||||
else if (interval === 120) postingFrequency = 'every_2_hours';
|
|
||||||
else if (interval === 240) postingFrequency = 'every_4_hours';
|
else if (interval === 240) postingFrequency = 'every_4_hours';
|
||||||
else if (interval === 360) postingFrequency = 'every_6_hours';
|
else if (interval === 360) postingFrequency = 'every_6_hours';
|
||||||
else if (interval === 1440) postingFrequency = 'daily';
|
else if (interval === 1440) postingFrequency = 'daily';
|
||||||
else {
|
else {
|
||||||
postingFrequency = 'custom';
|
// If it was hourly (60) or custom, default to every_4_hours (240)
|
||||||
customIntervalMinutes = interval;
|
postingFrequency = 'every_4_hours';
|
||||||
|
customIntervalMinutes = 240;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,13 +214,11 @@ export default function EditBotPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculate interval minutes
|
// Calculate interval minutes
|
||||||
let intervalMinutes = 60;
|
let intervalMinutes = 240;
|
||||||
if (formData.postingFrequency === 'hourly') intervalMinutes = 60;
|
if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
||||||
else if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
|
||||||
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
|
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
|
||||||
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
|
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
|
||||||
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
|
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
|
||||||
else if (formData.postingFrequency === 'custom') intervalMinutes = formData.customIntervalMinutes;
|
|
||||||
|
|
||||||
// Update bot
|
// Update bot
|
||||||
const updatePayload: Record<string, unknown> = {
|
const updatePayload: Record<string, unknown> = {
|
||||||
@@ -832,8 +831,8 @@ export default function EditBotPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '13px', fontWeight: 500 }}>
|
<div style={{ fontSize: '13px', fontWeight: 500 }}>
|
||||||
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
||||||
source.type === 'youtube' ? 'YOUTUBE' :
|
source.type === 'youtube' ? 'YOUTUBE' :
|
||||||
source.type.toUpperCase()} - {displayName}
|
source.type.toUpperCase()} - {displayName}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||||
{source.id ? 'Existing source' : 'New source (will be added)'}
|
{source.id ? 'Existing source' : 'New source (will be added)'}
|
||||||
@@ -886,36 +885,19 @@ export default function EditBotPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
disabled={!formData.autonomousMode}
|
disabled={!formData.autonomousMode}
|
||||||
>
|
>
|
||||||
<option value="hourly">Every Hour</option>
|
|
||||||
<option value="every_2_hours">Every 2 Hours</option>
|
<option value="every_2_hours">Every 2 Hours</option>
|
||||||
<option value="every_4_hours">Every 4 Hours</option>
|
<option value="every_4_hours">Every 4 Hours</option>
|
||||||
<option value="every_6_hours">Every 6 Hours</option>
|
<option value="every_6_hours">Every 6 Hours</option>
|
||||||
<option value="daily">Once Daily</option>
|
<option value="daily">Once Daily</option>
|
||||||
<option value="custom">Custom Interval</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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)' }}>
|
<div className="card" style={{ padding: '16px', background: 'var(--background-tertiary)' }}>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
|
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
|
||||||
{formData.autonomousMode
|
{formData.autonomousMode
|
||||||
? `Your bot will automatically post ${formData.postingFrequency === 'custom' ? `every ${formData.customIntervalMinutes} minutes` : formData.postingFrequency.replace('_', ' ')}.`
|
? `Your bot will automatically post ${formData.postingFrequency.replace('_', ' ')}.`
|
||||||
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export default function NewBotPage() {
|
|||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
llmApiKey: '',
|
llmApiKey: '',
|
||||||
autonomousMode: false,
|
autonomousMode: false,
|
||||||
postingFrequency: 'hourly',
|
postingFrequency: 'every_4_hours',
|
||||||
customIntervalMinutes: 60,
|
customIntervalMinutes: 240,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sources, setSources] = useState<ContentSource[]>([]);
|
const [sources, setSources] = useState<ContentSource[]>([]);
|
||||||
@@ -160,13 +160,11 @@ export default function NewBotPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculate interval minutes based on frequency
|
// Calculate interval minutes based on frequency
|
||||||
let intervalMinutes = 60;
|
let intervalMinutes = 240;
|
||||||
if (formData.postingFrequency === 'hourly') intervalMinutes = 60;
|
if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
||||||
else if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
|
||||||
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
|
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
|
||||||
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
|
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
|
||||||
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
|
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
|
||||||
else if (formData.postingFrequency === 'custom') intervalMinutes = formData.customIntervalMinutes;
|
|
||||||
|
|
||||||
const response = await fetch('/api/bots', {
|
const response = await fetch('/api/bots', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -764,12 +762,12 @@ export default function NewBotPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '13px', fontWeight: 500 }}>
|
<div style={{ fontSize: '13px', fontWeight: 500 }}>
|
||||||
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
||||||
source.type === 'youtube' ? 'YOUTUBE' :
|
source.type === 'youtube' ? 'YOUTUBE' :
|
||||||
source.type.toUpperCase()} - {
|
source.type.toUpperCase()} - {
|
||||||
source.type === 'brave_news' ? source.braveQuery :
|
source.type === 'brave_news' ? source.braveQuery :
|
||||||
source.type === 'news_api' ? source.newsQuery :
|
source.type === 'news_api' ? source.newsQuery :
|
||||||
source.type === 'youtube' ? (source.youtubeChannelId || source.youtubePlaylistId) :
|
source.type === 'youtube' ? (source.youtubeChannelId || source.youtubePlaylistId) :
|
||||||
source.subreddit || new URL(source.url).hostname
|
source.subreddit || new URL(source.url).hostname
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -819,36 +817,19 @@ export default function NewBotPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
disabled={!formData.autonomousMode}
|
disabled={!formData.autonomousMode}
|
||||||
>
|
>
|
||||||
<option value="hourly">Every Hour</option>
|
|
||||||
<option value="every_2_hours">Every 2 Hours</option>
|
<option value="every_2_hours">Every 2 Hours</option>
|
||||||
<option value="every_4_hours">Every 4 Hours</option>
|
<option value="every_4_hours">Every 4 Hours</option>
|
||||||
<option value="every_6_hours">Every 6 Hours</option>
|
<option value="every_6_hours">Every 6 Hours</option>
|
||||||
<option value="daily">Once Daily</option>
|
<option value="daily">Once Daily</option>
|
||||||
<option value="custom">Custom Interval</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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)' }}>
|
<div className="card" style={{ padding: '16px', background: 'var(--background-tertiary)' }}>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
|
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
|
||||||
{formData.autonomousMode
|
{formData.autonomousMode
|
||||||
? `Your bot will automatically post ${formData.postingFrequency === 'custom' ? `every ${formData.customIntervalMinutes} minutes` : formData.postingFrequency.replace('_', ' ')}.`
|
? `Your bot will automatically post ${formData.postingFrequency.replace('_', ' ')}.`
|
||||||
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
|
import { LogOut } from 'lucide-react';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { user, isAdmin } = useAuth();
|
const { user, isAdmin } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node')
|
fetch('/api/node')
|
||||||
@@ -47,9 +50,22 @@ export function Sidebar() {
|
|||||||
// Home is exact match
|
// Home is exact match
|
||||||
const isHome = pathname === '/';
|
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 (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<Link href="/" className="logo" style={{ minHeight: '42px' }}>
|
<Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}>
|
||||||
{customLogoUrl === undefined ? null : customLogoUrl ? (
|
{customLogoUrl === undefined ? null : customLogoUrl ? (
|
||||||
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
|
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
@@ -57,10 +73,12 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<nav>
|
<nav>
|
||||||
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
{user && (
|
||||||
<HomeIcon />
|
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
|
||||||
<span>Home</span>
|
<HomeIcon />
|
||||||
</Link>
|
<span>Home</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`}>
|
<Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`}>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
<span>Explore</span>
|
<span>Explore</span>
|
||||||
@@ -114,7 +132,7 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
{user && (
|
{user && (
|
||||||
<div style={{ marginTop: 'auto', paddingTop: '16px' }}>
|
<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 }}>
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<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()
|
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
|
||||||
)}
|
)}
|
||||||
</div>
|
</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={{ 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 style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatFullHandle(user.handle)}</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const nodes = pgTable('nodes', {
|
|||||||
publicKey: text('public_key'),
|
publicKey: text('public_key'),
|
||||||
// NSFW settings
|
// NSFW settings
|
||||||
isNsfw: boolean('is_nsfw').default(false).notNull(), // Entire node is NSFW
|
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(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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