feat(swarm,posts): Add NSFW filtering and link preview enrichment

- Add NSFW content filtering based on user's nsfwEnabled session setting
- Pass includeNsfw preference to fetchSwarmTimeline in curated and swarm feeds
- Remove in-memory caching from swarm API route to respect per-user preferences
- Add link preview enrichment for swarm posts with URLs but no preview data
- Implement extractFirstUrl utility to parse URLs from post content
- Implement fetchLinkPreview to fetch metadata from external URLs with 3s timeout
- Implement enrichPostsWithPreviews to enrich posts asynchronously
- Skip video URLs (YouTube, Vimeo) from preview enrichment as they use VideoEmbed
- Update swarm API documentation to reflect NSFW filtering behavior
- Improves content discovery by enriching posts with link metadata while respecting user content preferences
This commit is contained in:
AskIt
2026-01-26 03:27:14 +01:00
parent dc9ed98091
commit b7e8727986
3 changed files with 99 additions and 25 deletions
+5 -2
View File
@@ -329,17 +329,20 @@ export async function GET(request: Request) {
} else if (type === 'curated') { } else if (type === 'curated') {
// Curated feed - swarm posts only (no fediverse) // Curated feed - swarm posts only (no fediverse)
let viewer = null; let viewer = null;
let includeNsfw = false;
try { try {
const { getSession } = await import('@/lib/auth'); const { getSession } = await import('@/lib/auth');
const session = await getSession(); const session = await getSession();
viewer = session?.user || null; viewer = session?.user || null;
includeNsfw = session?.user?.nsfwEnabled ?? false;
} catch { } catch {
viewer = null; viewer = null;
includeNsfw = false;
} }
// Fetch swarm posts // Fetch swarm posts with user's NSFW preference
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline'); const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
const swarmResult = await fetchSwarmTimeline(10, 30); const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
// Transform swarm posts to match local post format // Transform swarm posts to match local post format
const swarmPosts = swarmResult.posts.map(sp => ({ const swarmPosts = swarmResult.posts.map(sp => ({
+11 -22
View File
@@ -6,41 +6,30 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth';
// Simple in-memory cache for swarm timeline
let cachedTimeline: Awaited<ReturnType<typeof fetchSwarmTimeline>> | null = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 60 * 1000; // 1 minute cache
/** /**
* GET /api/posts/swarm * GET /api/posts/swarm
* *
* Returns aggregated posts from across the swarm network. * Returns aggregated posts from across the swarm network.
* Results are cached for 1 minute to reduce load on other nodes. * NSFW content is included based on user's nsfwEnabled setting.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true'; const refresh = searchParams.get('refresh') === 'true';
const now = Date.now(); // Check user's NSFW preference
let includeNsfw = false;
// Return cached data if fresh try {
if (!refresh && cachedTimeline && (now - cacheTimestamp) < CACHE_TTL_MS) { const session = await getSession();
return NextResponse.json({ includeNsfw = session?.user?.nsfwEnabled ?? false;
posts: cachedTimeline.posts, } catch {
sources: cachedTimeline.sources, includeNsfw = false;
cached: true,
fetchedAt: cachedTimeline.fetchedAt,
});
} }
// Fetch fresh data // Fetch swarm timeline (no caching - user preferences vary)
const timeline = await fetchSwarmTimeline(10, 15); const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw });
// Update cache
cachedTimeline = timeline;
cacheTimestamp = now;
return NextResponse.json({ return NextResponse.json({
posts: timeline.posts, posts: timeline.posts,
+83 -1
View File
@@ -17,6 +17,85 @@ interface TimelineOptions {
includeNsfw?: boolean; // Whether to include NSFW content includeNsfw?: boolean; // Whether to include NSFW content
} }
/**
* Extract the first URL from post content
*/
function extractFirstUrl(content: string): string | null {
const urlMatch = content.match(/https?:\/\/[^\s<>"{}|\\^`[\]]+/);
if (!urlMatch) return null;
// Clean trailing punctuation
return urlMatch[0].replace(/[)\].,!?;:]+$/, '');
}
/**
* Fetch link preview for a URL
*/
async function fetchLinkPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
const response = await fetch(previewUrl, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return {
url: data.url || url,
title: data.title || null,
description: data.description || null,
image: data.image || null,
};
} catch {
return null;
}
}
/**
* Enrich swarm posts with link previews if they have URLs but no preview data
*/
async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]> {
const enrichmentPromises = posts.map(async (post) => {
// Skip if already has link preview data
if (post.linkPreviewUrl) return post;
// Extract URL from content
const url = extractFirstUrl(post.content);
if (!url) return post;
// Skip video URLs (handled by VideoEmbed component)
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
// Fetch preview
const preview = await fetchLinkPreview(url);
if (!preview) return post;
return {
...post,
linkPreviewUrl: preview.url,
linkPreviewTitle: preview.title || undefined,
linkPreviewDescription: preview.description || undefined,
linkPreviewImage: preview.image || undefined,
};
});
return Promise.all(enrichmentPromises);
}
/** /**
* Fetch timeline from a single node * Fetch timeline from a single node
*/ */
@@ -121,8 +200,11 @@ export async function fetchSwarmTimeline(
return true; return true;
}); });
// Enrich posts that have URLs but no link preview data
const enrichedPosts = await enrichPostsWithPreviews(uniquePosts);
return { return {
posts: uniquePosts, posts: enrichedPosts,
sources, sources,
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
}; };