feat(auth,explore): Add NSFW content gating and age verification
- Add automatic NSFW settings for users registering on NSFW nodes - Implement age verification checkbox on registration form for NSFW nodes - Add NSFW node detection on explore page with gated content access - Restrict unauthenticated users from viewing posts on NSFW nodes - Fetch and display node NSFW status in explore and login pages - Update node info type to include isNsfw flag across auth flows - Display age-gated content warning with EyeOff icon for restricted access - Enforce age verification requirement before registration on adult nodes
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { registerUser, createSession } from '@/lib/auth';
|
import { registerUser, createSession } from '@/lib/auth';
|
||||||
|
import { db, nodes, users } from '@/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -21,6 +23,22 @@ export async function POST(request: Request) {
|
|||||||
data.displayName
|
data.displayName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if this is an NSFW node and auto-enable NSFW settings
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
const node = await db.query.nodes.findFirst({
|
||||||
|
where: eq(nodes.domain, nodeDomain),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (node?.isNsfw) {
|
||||||
|
// Auto-enable NSFW viewing and mark account as NSFW for users on NSFW nodes
|
||||||
|
await db.update(users)
|
||||||
|
.set({
|
||||||
|
nsfwEnabled: true,
|
||||||
|
isNsfw: true
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Create session for new user
|
// Create session for new user
|
||||||
await createSession(user.id);
|
await createSession(user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
|
|||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import { Bot, Network, Server } from 'lucide-react';
|
import { Bot, Network, Server, EyeOff } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -75,6 +76,7 @@ interface SwarmPost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
|
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
|
||||||
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
||||||
@@ -84,6 +86,17 @@ export default function ExplorePage() {
|
|||||||
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||||
|
|
||||||
|
// Fetch node info to check if NSFW
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/node')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setIsNsfwNode(data.isNsfw || false);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load node posts (local only)
|
// Load node posts (local only)
|
||||||
@@ -232,7 +245,17 @@ export default function ExplorePage() {
|
|||||||
|
|
||||||
<div className="explore-content">
|
<div className="explore-content">
|
||||||
{activeTab === 'node' && (
|
{activeTab === 'node' && (
|
||||||
loading ? (
|
!user && isNsfwNode ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<EyeOff size={48} style={{ marginBottom: '16px', opacity: 0.5 }} />
|
||||||
|
<p style={{ fontSize: '16px', fontWeight: 500, color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
||||||
|
Adult Content
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '14px', maxWidth: '320px', margin: '0 auto' }}>
|
||||||
|
This node contains adult or sensitive content. You must be 18 or older and signed in to view posts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
<div className="explore-loading">Loading posts...</div>
|
<div className="explore-loading">Loading posts...</div>
|
||||||
) : nodePosts.length === 0 ? (
|
) : nodePosts.length === 0 ? (
|
||||||
<div className="explore-empty">
|
<div className="explore-empty">
|
||||||
|
|||||||
+33
-2
@@ -15,8 +15,9 @@ 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 }>({ name: '', description: '' });
|
const [nodeInfo, setNodeInfo] = useState<{ name: string; description: string; logoUrl?: string; isNsfw?: boolean }>({ 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);
|
||||||
|
|
||||||
// Import specific state
|
// Import specific state
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
@@ -33,7 +34,8 @@ export default function LoginPage() {
|
|||||||
setNodeInfo({
|
setNodeInfo({
|
||||||
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
|
||||||
});
|
});
|
||||||
setNodeInfoLoaded(true);
|
setNodeInfoLoaded(true);
|
||||||
})
|
})
|
||||||
@@ -122,6 +124,11 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'register' && nodeInfo.isNsfw && !ageVerified) {
|
||||||
|
setError('You must verify your age to register on this node');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -380,6 +387,30 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === 'register' && nodeInfo.isNsfw && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.05)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ageVerified}
|
||||||
|
onChange={(e) => setAgeVerified(e.target.checked)}
|
||||||
|
style={{ marginTop: '3px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||||
|
<strong style={{ color: 'var(--error)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<TriangleAlert size={12} /> Age Verification:
|
||||||
|
</strong> This node contains adult or sensitive content. I confirm that I am at least 18 years of age.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary btn-lg"
|
className="btn btn-primary btn-lg"
|
||||||
|
|||||||
+2
-5
@@ -159,17 +159,14 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* NSFW node gate for unauthenticated users */}
|
{/* NSFW node gate for unauthenticated users */}
|
||||||
{!user && isNsfwNode ? (
|
{!user && isNsfwNode ? (
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
<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 }} />
|
<EyeOff size={48} style={{ marginBottom: '16px', opacity: 0.5 }} />
|
||||||
<p style={{ fontSize: '16px', fontWeight: 500, color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
<p style={{ fontSize: '16px', fontWeight: 500, color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
|
||||||
Adult Content
|
Adult Content
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '14px', maxWidth: '320px', margin: '0 auto 20px' }}>
|
<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.
|
This node contains adult or sensitive content. You must be 18 or older and signed in to view posts.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login" className="btn btn-primary">
|
|
||||||
Sign In to Continue
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user