From e1a7f5018f40a77b53b013fa1d05ab7bb8288149 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 01:38:05 -0700 Subject: [PATCH] Show NSFW-node posts to authenticated local members in Curated and Swarm feeds Hop-State: A_06FP9V8EJ8CX3VM4D7DDZJR Hop-Proposal: R_06FP9V7H6GNGX6CFBQN8HER Hop-Task: T_06FP9TA4HBBMJPF6SDEMEC0 Hop-Attempt: AT_06FP9TA4HBQJXX3KT14FYN0 --- src/app/api/posts/route.ts | 21 +++++++++------------ src/app/api/posts/swarm/route.ts | 18 ++++++++---------- src/lib/node/local-node.ts | 17 +++++++++++++++++ src/lib/nsfw/feed-access.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/nsfw/feed-access.ts | 17 +++++++++++++++++ 5 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 src/lib/node/local-node.ts create mode 100644 src/lib/nsfw/feed-access.test.ts create mode 100644 src/lib/nsfw/feed-access.ts diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index c7a74f9..995ee06 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from 'next/server'; import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, userSwarmReposts, notifications } from '@/db'; -import { requireAuth } from '@/lib/auth'; +import { getSession, requireAuth } from '@/lib/auth'; import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm'; import { z } from 'zod'; import { buildNotificationTarget } from '@/lib/notifications'; import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; +import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access'; +import { isLocalNodeNsfw } from '@/lib/node/local-node'; const POST_MAX_LENGTH = 600; const CURATION_WINDOW_HOURS = 72; @@ -705,17 +707,12 @@ export async function GET(request: Request) { }); } else if (type === 'curated') { // Curated feed - swarm posts only - let viewer = null; - let includeNsfw = false; - try { - const { getSession } = await import('@/lib/auth'); - const session = await getSession(); - viewer = session?.user || null; - includeNsfw = session?.user?.nsfwEnabled ?? false; - } catch { - viewer = null; - includeNsfw = false; - } + const session = await getSession().catch(() => null); + const viewer = session?.user ?? null; + const includeNsfw = shouldIncludeNsfwFeed({ + viewer, + localNodeIsNsfw: await isLocalNodeNsfw(), + }); // Fetch swarm posts with user's NSFW preference const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline'); diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index f0a381a..689c007 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -9,6 +9,8 @@ import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { getSession } from '@/lib/auth'; import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; +import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access'; +import { isLocalNodeNsfw } from '@/lib/node/local-node'; type SwarmFeedPost = { id: string; @@ -65,20 +67,16 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const cursor = searchParams.get('cursor') || undefined; - // Check user's NSFW preference - let includeNsfw = false; - try { - const session = await getSession(); - includeNsfw = session?.user?.nsfwEnabled ?? false; - } catch { - includeNsfw = false; - } + const session = await getSession().catch(() => null); + const viewer = session?.user ?? null; + const includeNsfw = shouldIncludeNsfwFeed({ + viewer, + localNodeIsNsfw: await isLocalNodeNsfw(), + }); // Fetch swarm timeline (no caching - user preferences vary) const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor }); - const session = await getSession().catch(() => null); - const viewer = session?.user; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]); const likedPostIds = viewer diff --git a/src/lib/node/local-node.ts b/src/lib/node/local-node.ts new file mode 100644 index 0000000..4342db9 --- /dev/null +++ b/src/lib/node/local-node.ts @@ -0,0 +1,17 @@ +import { db } from '@/db'; + +export async function isLocalNodeNsfw(): Promise { + const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821'; + + try { + let node = await db.query.nodes.findFirst({ where: { domain } }); + if (!node) { + const localNodes = await db.query.nodes.findMany({ limit: 2 }); + if (localNodes.length === 1) node = localNodes[0]; + } + return node?.isNsfw === true; + } catch (error) { + console.error('Local node NSFW lookup failed:', error); + return false; + } +} diff --git a/src/lib/nsfw/feed-access.test.ts b/src/lib/nsfw/feed-access.test.ts new file mode 100644 index 0000000..ed2b78f --- /dev/null +++ b/src/lib/nsfw/feed-access.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { shouldIncludeNsfwFeed } from './feed-access'; + +describe('shouldIncludeNsfwFeed', () => { + it('keeps NSFW posts hidden from signed-out visitors on an NSFW node', () => { + expect(shouldIncludeNsfwFeed({ viewer: null, localNodeIsNsfw: true })).toBe(false); + }); + + it('includes NSFW posts for authenticated members of an NSFW node', () => { + expect(shouldIncludeNsfwFeed({ + viewer: { nsfwEnabled: false }, + localNodeIsNsfw: true, + })).toBe(true); + }); + + it('respects an enabled account preference on a general-purpose node', () => { + expect(shouldIncludeNsfwFeed({ + viewer: { nsfwEnabled: true }, + localNodeIsNsfw: false, + })).toBe(true); + }); + + it('filters NSFW posts when neither form of consent is present', () => { + expect(shouldIncludeNsfwFeed({ + viewer: { nsfwEnabled: false }, + localNodeIsNsfw: false, + })).toBe(false); + }); +}); diff --git a/src/lib/nsfw/feed-access.ts b/src/lib/nsfw/feed-access.ts new file mode 100644 index 0000000..9923654 --- /dev/null +++ b/src/lib/nsfw/feed-access.ts @@ -0,0 +1,17 @@ +export interface NsfwFeedViewer { + nsfwEnabled?: boolean; +} + +export function shouldIncludeNsfwFeed({ + viewer, + localNodeIsNsfw, +}: { + viewer: NsfwFeedViewer | null; + localNodeIsNsfw: boolean; +}): boolean { + if (!viewer) return false; + + // Signing in to an NSFW node is consent to view its feed. On other nodes, + // the viewer's explicit account preference remains authoritative. + return localNodeIsNsfw || viewer.nsfwEnabled === true; +}