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
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, userSwarmReposts, notifications } from '@/db';
|
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 { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { buildNotificationTarget } from '@/lib/notifications';
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
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 POST_MAX_LENGTH = 600;
|
||||||
const CURATION_WINDOW_HOURS = 72;
|
const CURATION_WINDOW_HOURS = 72;
|
||||||
@@ -705,17 +707,12 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
} else if (type === 'curated') {
|
} else if (type === 'curated') {
|
||||||
// Curated feed - swarm posts only
|
// Curated feed - swarm posts only
|
||||||
let viewer = null;
|
const session = await getSession().catch(() => null);
|
||||||
let includeNsfw = false;
|
const viewer = session?.user ?? null;
|
||||||
try {
|
const includeNsfw = shouldIncludeNsfwFeed({
|
||||||
const { getSession } = await import('@/lib/auth');
|
viewer,
|
||||||
const session = await getSession();
|
localNodeIsNsfw: await isLocalNodeNsfw(),
|
||||||
viewer = session?.user || null;
|
});
|
||||||
includeNsfw = session?.user?.nsfwEnabled ?? false;
|
|
||||||
} catch {
|
|
||||||
viewer = null;
|
|
||||||
includeNsfw = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch swarm posts with user's NSFW preference
|
// Fetch swarm posts with user's NSFW preference
|
||||||
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
|||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||||
|
import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access';
|
||||||
|
import { isLocalNodeNsfw } from '@/lib/node/local-node';
|
||||||
|
|
||||||
type SwarmFeedPost = {
|
type SwarmFeedPost = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,20 +67,16 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const cursor = searchParams.get('cursor') || undefined;
|
const cursor = searchParams.get('cursor') || undefined;
|
||||||
|
|
||||||
// Check user's NSFW preference
|
const session = await getSession().catch(() => null);
|
||||||
let includeNsfw = false;
|
const viewer = session?.user ?? null;
|
||||||
try {
|
const includeNsfw = shouldIncludeNsfwFeed({
|
||||||
const session = await getSession();
|
viewer,
|
||||||
includeNsfw = session?.user?.nsfwEnabled ?? false;
|
localNodeIsNsfw: await isLocalNodeNsfw(),
|
||||||
} catch {
|
});
|
||||||
includeNsfw = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch swarm timeline (no caching - user preferences vary)
|
// Fetch swarm timeline (no caching - user preferences vary)
|
||||||
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
|
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 nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
|
const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
|
||||||
const likedPostIds = viewer
|
const likedPostIds = viewer
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { db } from '@/db';
|
||||||
|
|
||||||
|
export async function isLocalNodeNsfw(): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user