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:
AskIt
2026-01-26 01:54:02 +01:00
parent 091c85080b
commit 981441bfe5
4 changed files with 78 additions and 9 deletions
+18
View File
@@ -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);
+25 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)' }}>