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:
@@ -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 => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user