feat(swarm): Implement decentralized node discovery and content federation system

- Add swarm node registry with discovery, gossip, and timeline synchronization
- Implement database schema for swarm nodes, seeds, and sync logs with proper indexing
- Create swarm API endpoints for node discovery, gossip protocol, timeline sharing, and announcements
- Add content moderation features with NSFW flagging and muted nodes support
- Implement user settings pages for content filtering and node moderation
- Add background scheduler for automated swarm synchronization and node health checks
- Create .well-known endpoint for swarm protocol discovery
- Remove legacy bot-cron.ts in favor of integrated scheduler system
- Add user account NSFW preferences and age verification tracking
- Update database schema with swarm-related tables and user moderation fields
- Enhance post and user components to support federated content display
- Add instrumentation for monitoring swarm operations and node health
This commit is contained in:
AskIt
2026-01-25 23:01:29 +01:00
parent 765845bd89
commit e2fa572e84
42 changed files with 10829 additions and 164 deletions
-63
View File
@@ -1,63 +0,0 @@
/**
* Bot Cron Job Script
*
* Run with PM2:
* pm2 start bot-cron.ts --name "bot-cron" --cron "* * * * *" --no-autorestart
*
* Or for continuous running with internal interval:
* pm2 start bot-cron.ts --name "bot-cron"
*/
const INTERVAL_MS = 60 * 1000; // 1 minute
const API_URL = process.env.NEXT_PUBLIC_NODE_DOMAIN
? `https://${process.env.NEXT_PUBLIC_NODE_DOMAIN}/api/cron/bots`
: 'http://localhost:3000/api/cron/bots';
const AUTH_SECRET = process.env.AUTH_SECRET || '';
async function runCron() {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] Running bot cron job...`);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (AUTH_SECRET) {
headers['Authorization'] = `Bearer ${AUTH_SECRET}`;
}
const response = await fetch(API_URL, {
method: 'POST',
headers,
});
const data = await response.json();
if (response.ok) {
console.log(`[${timestamp}] Cron completed:`, JSON.stringify(data, null, 2));
} else {
console.error(`[${timestamp}] Cron failed:`, data);
}
} catch (error) {
console.error(`[${timestamp}] Cron error:`, error);
}
}
// Check if running with PM2 cron (single execution) or continuous mode
const isPM2Cron = process.env.PM2_CRON === 'true' || process.argv.includes('--once');
if (isPM2Cron) {
// Single execution mode (PM2 handles scheduling)
runCron().then(() => process.exit(0));
} else {
// Continuous mode with internal interval
console.log(`Bot cron started. Running every ${INTERVAL_MS / 1000} seconds.`);
console.log(`API URL: ${API_URL}`);
// Run immediately on start
runCron();
// Then run on interval
setInterval(runCron, INTERVAL_MS);
}
+56
View File
@@ -0,0 +1,56 @@
CREATE TABLE "swarm_nodes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"domain" text NOT NULL,
"name" text,
"description" text,
"logo_url" text,
"public_key" text,
"software_version" text,
"user_count" integer,
"post_count" integer,
"discovered_via" text,
"discovered_at" timestamp DEFAULT now() NOT NULL,
"last_seen_at" timestamp DEFAULT now() NOT NULL,
"last_sync_at" timestamp,
"consecutive_failures" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"trust_score" integer DEFAULT 50 NOT NULL,
"capabilities" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "swarm_nodes_domain_unique" UNIQUE("domain")
);
--> statement-breakpoint
CREATE TABLE "swarm_seeds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"domain" text NOT NULL,
"priority" integer DEFAULT 100 NOT NULL,
"is_enabled" boolean DEFAULT true NOT NULL,
"last_contact_at" timestamp,
"consecutive_failures" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "swarm_seeds_domain_unique" UNIQUE("domain")
);
--> statement-breakpoint
CREATE TABLE "swarm_sync_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"remote_domain" text NOT NULL,
"direction" text NOT NULL,
"nodes_received" integer DEFAULT 0 NOT NULL,
"nodes_sent" integer DEFAULT 0 NOT NULL,
"handles_received" integer DEFAULT 0 NOT NULL,
"handles_sent" integer DEFAULT 0 NOT NULL,
"success" boolean NOT NULL,
"error_message" text,
"duration_ms" integer,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "swarm_nodes_domain_idx" ON "swarm_nodes" USING btree ("domain");--> statement-breakpoint
CREATE INDEX "swarm_nodes_active_idx" ON "swarm_nodes" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "swarm_nodes_last_seen_idx" ON "swarm_nodes" USING btree ("last_seen_at");--> statement-breakpoint
CREATE INDEX "swarm_nodes_trust_idx" ON "swarm_nodes" USING btree ("trust_score");--> statement-breakpoint
CREATE INDEX "swarm_seeds_enabled_idx" ON "swarm_seeds" USING btree ("is_enabled");--> statement-breakpoint
CREATE INDEX "swarm_seeds_priority_idx" ON "swarm_seeds" USING btree ("priority");--> statement-breakpoint
CREATE INDEX "swarm_sync_log_remote_idx" ON "swarm_sync_log" USING btree ("remote_domain");--> statement-breakpoint
CREATE INDEX "swarm_sync_log_created_idx" ON "swarm_sync_log" USING btree ("created_at");
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "muted_nodes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"node_domain" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "nodes" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "swarm_nodes" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "is_nsfw" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "nsfw_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "age_verified_at" timestamp;--> statement-breakpoint
ALTER TABLE "muted_nodes" ADD CONSTRAINT "muted_nodes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "muted_nodes_user_idx" ON "muted_nodes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "muted_nodes_domain_idx" ON "muted_nodes" USING btree ("node_domain");--> statement-breakpoint
CREATE INDEX "blocks_blocked_user_idx" ON "blocks" USING btree ("blocked_user_id");--> statement-breakpoint
CREATE INDEX "mutes_muted_user_idx" ON "mutes" USING btree ("muted_user_id");--> statement-breakpoint
CREATE INDEX "posts_nsfw_idx" ON "posts" USING btree ("is_nsfw");--> statement-breakpoint
CREATE INDEX "swarm_nodes_nsfw_idx" ON "swarm_nodes" USING btree ("is_nsfw");--> statement-breakpoint
CREATE INDEX "users_nsfw_idx" ON "users" USING btree ("is_nsfw");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -22,6 +22,20 @@
"when": 1769367465905, "when": 1769367465905,
"tag": "0002_add_logo_url", "tag": "0002_add_logo_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1769370833410,
"tag": "0003_small_reavers",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1769377790354,
"tag": "0004_luxuriant_lockheed",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,77 @@
/**
* Well-Known Synapsis Swarm Endpoint
*
* GET /.well-known/synapsis-swarm
*
* Returns information about this node and the swarm it knows about.
* This is a standardized discovery endpoint that other nodes can use
* to find and join the swarm.
*/
import { NextResponse } from 'next/server';
import { buildAnnouncement } from '@/lib/swarm/discovery';
import { getActiveSwarmNodes, getSwarmStats, getSeedNodes } from '@/lib/swarm/registry';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const includeNodes = searchParams.get('nodes') !== 'false';
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const announcement = await buildAnnouncement();
const stats = await getSwarmStats();
const seeds = await getSeedNodes();
const response: {
// This node's info
node: {
domain: string;
name: string;
description?: string;
publicKey: string;
softwareVersion: string;
capabilities: string[];
};
// Swarm metadata
swarm: {
totalNodes: number;
activeNodes: number;
totalUsers: number;
totalPosts: number;
seeds: string[];
};
// Known nodes (optional)
nodes?: Awaited<ReturnType<typeof getActiveSwarmNodes>>;
} = {
node: {
domain: announcement.domain,
name: announcement.name,
description: announcement.description,
publicKey: announcement.publicKey,
softwareVersion: announcement.softwareVersion,
capabilities: announcement.capabilities,
},
swarm: {
...stats,
seeds,
},
};
if (includeNodes) {
response.nodes = await getActiveSwarmNodes(limit);
}
return NextResponse.json(response, {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
},
});
} catch (error) {
console.error('Synapsis swarm well-known error:', error);
return NextResponse.json(
{ error: 'Failed to fetch swarm info' },
{ status: 500 }
);
}
}
+79 -2
View File
@@ -7,7 +7,7 @@ import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
import { PostCard } from '@/components/PostCard'; import { PostCard } from '@/components/PostCard';
import { User, Post } from '@/lib/types'; import { User, Post } from '@/lib/types';
import AutoTextarea from '@/components/AutoTextarea'; import AutoTextarea from '@/components/AutoTextarea';
import { Rocket } from 'lucide-react'; import { Rocket, MoreHorizontal } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
@@ -102,6 +102,8 @@ export default function ProfilePage() {
}); });
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isBlocked, setIsBlocked] = useState(false);
const [showMenu, setShowMenu] = useState(false);
useEffect(() => { useEffect(() => {
setIsEditing(false); setIsEditing(false);
@@ -173,6 +175,7 @@ export default function ProfilePage() {
useEffect(() => { useEffect(() => {
if (!currentUser || !user || currentUser.handle === user.handle) { if (!currentUser || !user || currentUser.handle === user.handle) {
setIsFollowing(false); setIsFollowing(false);
setIsBlocked(false);
return; return;
} }
@@ -180,6 +183,11 @@ export default function ProfilePage() {
.then(res => res.json()) .then(res => res.json())
.then(data => setIsFollowing(!!data.following)) .then(data => setIsFollowing(!!data.following))
.catch(() => setIsFollowing(false)); .catch(() => setIsFollowing(false));
fetch(`/api/users/${handle}/block`)
.then(res => res.json())
.then(data => setIsBlocked(!!data.blocked))
.catch(() => setIsBlocked(false));
}, [currentUser, user, handle]); }, [currentUser, user, handle]);
useEffect(() => { useEffect(() => {
@@ -226,6 +234,22 @@ export default function ProfilePage() {
} }
}; };
const handleBlock = async () => {
if (!currentUser) return;
const method = isBlocked ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${handle}/block`, { method });
if (res.ok) {
setIsBlocked(!isBlocked);
if (!isBlocked) {
// If blocking, also unfollow
setIsFollowing(false);
}
setShowMenu(false);
}
};
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
if (!isOwnProfile) return; if (!isOwnProfile) return;
setIsSaving(true); setIsSaving(true);
@@ -377,8 +401,10 @@ export default function ProfilePage() {
)} )}
</div> </div>
<div style={{ paddingTop: '12px' }}> <div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
{!isOwnProfile && currentUser && ( {!isOwnProfile && currentUser && (
<>
{!isBlocked && (
<button <button
className={`btn ${isFollowing ? '' : 'btn-primary'}`} className={`btn ${isFollowing ? '' : 'btn-primary'}`}
onClick={handleFollow} onClick={handleFollow}
@@ -386,6 +412,57 @@ export default function ProfilePage() {
{isFollowing ? 'Following' : 'Follow'} {isFollowing ? 'Following' : 'Follow'}
</button> </button>
)} )}
<div style={{ position: 'relative' }}>
<button
className="btn btn-ghost"
onClick={() => setShowMenu(!showMenu)}
style={{ padding: '8px' }}
>
<MoreHorizontal size={20} />
</button>
{showMenu && (
<>
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99,
}}
onClick={() => setShowMenu(false)}
/>
<div style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: '4px',
background: 'var(--background-secondary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
minWidth: '160px',
zIndex: 100,
overflow: 'hidden',
}}>
<button
onClick={handleBlock}
style={{
width: '100%',
padding: '12px 16px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: isBlocked ? 'var(--foreground)' : 'var(--error)',
fontSize: '14px',
}}
>
{isBlocked ? 'Unblock user' : 'Block user'}
</button>
</div>
</>
)}
</div>
</>
)}
{isOwnProfile && ( {isOwnProfile && (
<button className="btn" onClick={() => setIsEditing(!isEditing)}> <button className="btn" onClick={() => setIsEditing(!isEditing)}>
+2 -2
View File
@@ -41,11 +41,11 @@ export default function PostDetailPage() {
fetchPostDetail(); fetchPostDetail();
}, [id]); }, [id]);
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => { const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
const res = await fetch('/api/posts', { const res = await fetch('/api/posts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }), body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }),
}); });
if (res.ok) { if (res.ok) {
+45
View File
@@ -70,6 +70,7 @@ export default function AdminPage() {
bannerUrl: '', bannerUrl: '',
logoUrl: '', logoUrl: '',
accentColor: '#00D4AA', accentColor: '#00D4AA',
isNsfw: false,
}); });
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [isUploadingBanner, setIsUploadingBanner] = useState(false);
@@ -136,6 +137,7 @@ export default function AdminPage() {
bannerUrl: data.bannerUrl || '', bannerUrl: data.bannerUrl || '',
logoUrl: data.logoUrl || '', logoUrl: data.logoUrl || '',
accentColor: data.accentColor || '#00D4AA', accentColor: data.accentColor || '#00D4AA',
isNsfw: data.isNsfw || false,
}); });
} catch { } catch {
// error // error
@@ -682,6 +684,49 @@ export default function AdminPage() {
/> />
</div> </div>
<div style={{
padding: '16px',
background: nodeSettings.isNsfw ? 'rgba(239, 68, 68, 0.1)' : 'var(--background-secondary)',
borderRadius: '8px',
border: nodeSettings.isNsfw ? '1px solid var(--error)' : '1px solid var(--border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px' }}>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', display: 'block' }}>
NSFW Node
</label>
<p style={{ fontSize: '12px', color: 'var(--foreground-secondary)', margin: 0 }}>
{nodeSettings.isNsfw
? 'This node is marked as NSFW. All content will be hidden from users who haven\'t enabled NSFW viewing.'
: 'Enable this if your node primarily hosts adult or sensitive content. All posts from this node will be treated as NSFW across the swarm.'}
</p>
</div>
<button
className={`btn btn-sm ${nodeSettings.isNsfw ? 'btn-primary' : 'btn-ghost'}`}
style={{
background: nodeSettings.isNsfw ? 'var(--error)' : undefined,
flexShrink: 0,
}}
onClick={() => {
if (!nodeSettings.isNsfw) {
const confirmed = window.confirm(
'Are you sure you want to mark this node as NSFW?\n\n' +
'All content from this node will be hidden from users who haven\'t enabled NSFW viewing. ' +
'This affects the entire swarm.'
);
if (confirmed) {
setNodeSettings({ ...nodeSettings, isNsfw: true });
}
} else {
setNodeSettings({ ...nodeSettings, isNsfw: false });
}
}}
>
{nodeSettings.isNsfw ? 'Remove NSFW' : 'Mark as NSFW'}
</button>
</div>
</div>
<div style={{ paddingTop: '8px' }}> <div style={{ paddingTop: '8px' }}>
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}> <button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
{savingSettings ? 'Saving...' : 'Save Settings'} {savingSettings ? 'Saving...' : 'Save Settings'}
+2
View File
@@ -25,6 +25,7 @@ export async function PATCH(req: NextRequest) {
bannerUrl: data.bannerUrl, bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl, logoUrl: data.logoUrl,
accentColor: data.accentColor, accentColor: data.accentColor,
isNsfw: data.isNsfw ?? false,
}).returning(); }).returning();
} else { } else {
[node] = await db.update(nodes) [node] = await db.update(nodes)
@@ -36,6 +37,7 @@ export async function PATCH(req: NextRequest) {
bannerUrl: data.bannerUrl, bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl, logoUrl: data.logoUrl,
accentColor: data.accentColor, accentColor: data.accentColor,
isNsfw: data.isNsfw ?? node.isNsfw,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(nodes.id, node.id)) .where(eq(nodes.id, node.id))
+55
View File
@@ -0,0 +1,55 @@
/**
* Cron endpoint for swarm operations
*
* Called periodically to run gossip rounds and maintain swarm health
*/
import { NextRequest, NextResponse } from 'next/server';
import { runGossipRound } from '@/lib/swarm/gossip';
import { announceToSeeds } from '@/lib/swarm/discovery';
import { getSwarmStats } from '@/lib/swarm/registry';
export async function POST(request: NextRequest) {
// Verify using AUTH_SECRET to prevent unauthorized access
const authHeader = request.headers.get('authorization');
const authSecret = process.env.AUTH_SECRET;
if (authSecret && authHeader !== `Bearer ${authSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const action = searchParams.get('action') || 'gossip';
try {
if (action === 'announce') {
// Announce to seed nodes (used on startup)
const result = await announceToSeeds();
return NextResponse.json({
success: true,
action: 'announce',
successful: result.successful,
failed: result.failed,
timestamp: new Date().toISOString(),
});
}
// Default: run gossip round
const gossipResult = await runGossipRound();
const stats = await getSwarmStats();
return NextResponse.json({
success: true,
action: 'gossip',
gossip: gossipResult,
swarm: stats,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Cron swarm processing error:', error);
return NextResponse.json(
{ error: 'Failed to process swarm', details: String(error) },
{ status: 500 }
);
}
}
+35 -55
View File
@@ -20,6 +20,7 @@ const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH), content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().uuid().optional(), replyToId: z.string().uuid().optional(),
mediaIds: z.array(z.string().uuid()).max(4).optional(), mediaIds: z.array(z.string().uuid()).max(4).optional(),
isNsfw: z.boolean().optional(),
linkPreview: z.object({ linkPreview: z.object({
url: z.string().url(), url: z.string().url(),
title: z.string().optional(), title: z.string().optional(),
@@ -45,6 +46,7 @@ export async function POST(request: Request) {
userId: user.id, userId: user.id,
content: data.content, content: data.content,
replyToId: data.replyToId, replyToId: data.replyToId,
isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
linkPreviewUrl: data.linkPreview?.url, linkPreviewUrl: data.linkPreview?.url,
@@ -282,6 +284,7 @@ export async function GET(request: Request) {
limit, limit,
}); });
} else if (type === 'curated') { } else if (type === 'curated') {
// Curated feed - swarm posts only (no fediverse)
let viewer = null; let viewer = null;
try { try {
const { getSession } = await import('@/lib/auth'); const { getSession } = await import('@/lib/auth');
@@ -291,48 +294,40 @@ export async function GET(request: Request) {
viewer = null; viewer = null;
} }
const seedLimit = Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP); // Fetch swarm posts
const seedPosts = await db.query.posts.findMany({ const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
where: baseFilter, const swarmResult = await fetchSwarmTimeline(10, 30);
with: {
author: true, // Transform swarm posts to match local post format
bot: true, const swarmPosts = swarmResult.posts.map(sp => ({
media: true, id: `swarm:${sp.nodeDomain}:${sp.id}`,
replyTo: { content: sp.content,
with: { author: true }, createdAt: new Date(sp.createdAt),
likesCount: sp.likeCount,
repostsCount: sp.repostCount,
repliesCount: sp.replyCount,
isSwarm: true,
nodeDomain: sp.nodeDomain,
author: {
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
handle: sp.author.handle,
displayName: sp.author.displayName,
avatarUrl: sp.author.avatarUrl,
isSwarm: true,
nodeDomain: sp.nodeDomain,
}, },
}, media: sp.mediaUrls?.map((url, idx) => ({
orderBy: [desc(posts.createdAt)], id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`,
limit: seedLimit, url,
}); altText: null,
})) || [],
replyTo: null,
}));
// Also get cached remote posts for the curated feed
const remotePostsData = await db.query.remotePosts.findMany({
orderBy: [desc(remotePosts.publishedAt)],
limit: Math.floor(seedLimit / 2),
});
const transformedRemote = transformRemotePosts(remotePostsData);
// Combine local and remote for ranking
const allSeedPosts = [...seedPosts, ...transformedRemote];
let followingIds = new Set<string>();
let followingRemoteHandles = new Set<string>();
let mutedIds = new Set<string>(); let mutedIds = new Set<string>();
let blockedIds = new Set<string>(); let blockedIds = new Set<string>();
if (viewer) { if (viewer) {
const followRows = await db.select({ followingId: follows.followingId })
.from(follows)
.where(eq(follows.followerId, viewer.id));
followingIds = new Set(followRows.map(row => row.followingId));
// Also get remote follows for boost
const remoteFollowRows = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, viewer.id),
});
followingRemoteHandles = new Set(remoteFollowRows.map(row => row.targetHandle));
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId }) const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
.from(mutes) .from(mutes)
.where(eq(mutes.userId, viewer.id)); .where(eq(mutes.userId, viewer.id));
@@ -345,7 +340,7 @@ export async function GET(request: Request) {
} }
const now = Date.now(); const now = Date.now();
const rankedPosts = allSeedPosts const rankedPosts = swarmPosts
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)) .filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
.map((post: any) => { .map((post: any) => {
const createdAt = new Date(post.createdAt).getTime(); const createdAt = new Date(post.createdAt).getTime();
@@ -354,23 +349,10 @@ export async function GET(request: Request) {
const engagementScore = Math.log1p(Math.max(0, engagement)); const engagementScore = Math.log1p(Math.max(0, engagement));
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS); const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
const isRemote = post.isRemote === true; const score = engagementScore * 1.4 + recencyScore * 1.1;
const followBoost = viewer && (
followingIds.has(post.author.id) ||
(isRemote && followingRemoteHandles.has(post.author.handle))
) ? 0.9 : 0;
const selfBoost = viewer && post.author.id === viewer.id ? 0.5 : 0;
const federatedBoost = isRemote ? 0.3 : 0; // Small boost for federated content diversity
const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost + federatedBoost;
const reasons: string[] = []; const reasons: string[] = [];
if (isRemote) { reasons.push(`From ${post.nodeDomain}`);
reasons.push(`From the fediverse`);
}
if (followBoost > 0) {
reasons.push(`You follow @${post.author.handle}`);
}
if (engagement >= 5) { if (engagement >= 5) {
reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`); reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`);
} else if ((post.repliesCount || 0) > 0) { } else if ((post.repliesCount || 0) > 0) {
@@ -380,10 +362,8 @@ export async function GET(request: Request) {
reasons.push('Posted recently'); reasons.push('Posted recently');
} else if (ageHours <= 24) { } else if (ageHours <= 24) {
reasons.push('Posted today'); reasons.push('Posted today');
} else if (ageHours <= CURATION_WINDOW_HOURS) {
reasons.push('Recent');
} }
if (reasons.length === 0) { if (reasons.length === 1) {
reasons.push('New post'); reasons.push('New post');
} }
+58
View File
@@ -0,0 +1,58 @@
/**
* Swarm Posts Endpoint
*
* GET: Returns aggregated posts from across the swarm
*/
import { NextRequest, NextResponse } from 'next/server';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
// 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
*
* Returns aggregated posts from across the swarm network.
* Results are cached for 1 minute to reduce load on other nodes.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true';
const now = Date.now();
// Return cached data if fresh
if (!refresh && cachedTimeline && (now - cacheTimestamp) < CACHE_TTL_MS) {
return NextResponse.json({
posts: cachedTimeline.posts,
sources: cachedTimeline.sources,
cached: true,
fetchedAt: cachedTimeline.fetchedAt,
});
}
// Fetch fresh data
const timeline = await fetchSwarmTimeline(10, 15);
// Update cache
cachedTimeline = timeline;
cacheTimestamp = now;
return NextResponse.json({
posts: timeline.posts,
sources: timeline.sources,
cached: false,
fetchedAt: timeline.fetchedAt,
});
} catch (error) {
console.error('Swarm posts error:', error);
return NextResponse.json(
{ error: 'Failed to fetch swarm posts' },
{ status: 500 }
);
}
}
@@ -0,0 +1,53 @@
/**
* Account NSFW Setting API
*
* POST: Mark/unmark your account as NSFW (content creator setting)
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
const updateSchema = z.object({
isNsfw: z.boolean(),
});
/**
* POST /api/settings/account-nsfw
*
* Mark your account as producing NSFW content.
* All your posts will be treated as NSFW.
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
const body = await request.json();
const { isNsfw } = updateSchema.parse(body);
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
}
await db.update(users)
.set({
isNsfw,
updatedAt: new Date(),
})
.where(eq(users.id, user.id));
return NextResponse.json({
success: true,
isNsfw,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { blocks, users } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
// GET - List blocked users
export async function GET() {
try {
const currentUser = await requireAuth();
const blocked = await db.query.blocks.findMany({
where: eq(blocks.userId, currentUser.id),
with: {
blockedUser: true,
},
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
return NextResponse.json({
blockedUsers: blocked.map(b => ({
id: b.blockedUser.id,
handle: b.blockedUser.handle,
displayName: b.blockedUser.displayName,
avatarUrl: b.blockedUser.avatarUrl,
blockedAt: b.createdAt.toISOString(),
})),
});
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Get blocked users error:', error);
return NextResponse.json({ error: 'Failed to get blocked users' }, { status: 500 });
}
}
// DELETE - Unblock a user by ID
export async function DELETE(req: NextRequest) {
try {
const currentUser = await requireAuth();
const { searchParams } = new URL(req.url);
const userId = searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
}
await db.delete(blocks).where(
and(
eq(blocks.userId, currentUser.id),
eq(blocks.blockedUserId, userId)
)
);
return NextResponse.json({ unblocked: true });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Unblock user error:', error);
return NextResponse.json({ error: 'Failed to unblock user' }, { status: 500 });
}
}
+99
View File
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { mutedNodes } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
// GET - List muted nodes
export async function GET() {
try {
const currentUser = await requireAuth();
const muted = await db.query.mutedNodes.findMany({
where: eq(mutedNodes.userId, currentUser.id),
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
return NextResponse.json({
mutedNodes: muted.map(m => ({
domain: m.nodeDomain,
mutedAt: m.createdAt.toISOString(),
})),
});
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Get muted nodes error:', error);
return NextResponse.json({ error: 'Failed to get muted nodes' }, { status: 500 });
}
}
// POST - Mute a node
export async function POST(req: NextRequest) {
try {
const currentUser = await requireAuth();
const { domain } = await req.json();
if (!domain || typeof domain !== 'string') {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 });
}
const normalizedDomain = domain.toLowerCase().trim();
// Check if already muted
const existing = await db.query.mutedNodes.findFirst({
where: and(
eq(mutedNodes.userId, currentUser.id),
eq(mutedNodes.nodeDomain, normalizedDomain)
),
});
if (existing) {
return NextResponse.json({ muted: true, domain: normalizedDomain });
}
await db.insert(mutedNodes).values({
userId: currentUser.id,
nodeDomain: normalizedDomain,
});
return NextResponse.json({ muted: true, domain: normalizedDomain });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Mute node error:', error);
return NextResponse.json({ error: 'Failed to mute node' }, { status: 500 });
}
}
// DELETE - Unmute a node
export async function DELETE(req: NextRequest) {
try {
const currentUser = await requireAuth();
const { searchParams } = new URL(req.url);
const domain = searchParams.get('domain');
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 });
}
const normalizedDomain = domain.toLowerCase().trim();
await db.delete(mutedNodes).where(
and(
eq(mutedNodes.userId, currentUser.id),
eq(mutedNodes.nodeDomain, normalizedDomain)
)
);
return NextResponse.json({ muted: false, domain: normalizedDomain });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Unmute node error:', error);
return NextResponse.json({ error: 'Failed to unmute node' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* NSFW Settings API
*
* GET: Get current user's NSFW settings
* POST: Update NSFW settings (requires age verification for enabling)
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
const updateSchema = z.object({
nsfwEnabled: z.boolean(),
confirmAge: z.boolean().optional(), // Must be true when enabling NSFW
});
/**
* GET /api/settings/nsfw
*
* Returns current user's NSFW settings
*/
export async function GET() {
try {
const user = await requireAuth();
return NextResponse.json({
nsfwEnabled: user.nsfwEnabled,
ageVerifiedAt: user.ageVerifiedAt?.toISOString() || null,
isNsfw: user.isNsfw, // Whether their account is marked NSFW
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to get settings' }, { status: 500 });
}
}
/**
* POST /api/settings/nsfw
*
* Update NSFW settings. Enabling requires age confirmation.
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
const body = await request.json();
const { nsfwEnabled, confirmAge } = updateSchema.parse(body);
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
}
// If enabling NSFW and not already verified, require age confirmation
if (nsfwEnabled && !user.ageVerifiedAt) {
if (!confirmAge) {
return NextResponse.json({
error: 'Age verification required',
requiresAgeConfirmation: true,
message: 'You must confirm you are 18 or older to view NSFW content',
}, { status: 400 });
}
// Record age verification
await db.update(users)
.set({
nsfwEnabled: true,
ageVerifiedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(users.id, user.id));
return NextResponse.json({
success: true,
nsfwEnabled: true,
ageVerifiedAt: new Date().toISOString(),
});
}
// Update preference (already verified or disabling)
await db.update(users)
.set({
nsfwEnabled,
updatedAt: new Date(),
})
.where(eq(users.id, user.id));
return NextResponse.json({
success: true,
nsfwEnabled,
ageVerifiedAt: user.ageVerifiedAt?.toISOString() || null,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Swarm Announce Endpoint
*
* POST: Receive announcements from other nodes joining the swarm
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { upsertSwarmNode } from '@/lib/swarm/registry';
import { buildAnnouncement } from '@/lib/swarm/discovery';
import type { SwarmNodeInfo } from '@/lib/swarm/types';
const announcementSchema = z.object({
domain: z.string().min(1),
name: z.string().optional(),
description: z.string().optional(),
logoUrl: z.string().url().optional(),
publicKey: z.string().optional(),
softwareVersion: z.string().optional(),
userCount: z.number().optional(),
postCount: z.number().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search'])).optional(),
timestamp: z.string().optional(),
signature: z.string().optional(),
});
/**
* POST /api/swarm/announce
*
* Receives an announcement from another node and responds with our info.
* This is how nodes introduce themselves to the swarm.
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const announcement = announcementSchema.parse(body);
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't process announcements from ourselves
if (announcement.domain === ourDomain) {
return NextResponse.json(
{ error: 'Cannot announce to self' },
{ status: 400 }
);
}
// Add/update the announcing node in our registry
const nodeInfo: SwarmNodeInfo = {
domain: announcement.domain,
name: announcement.name,
description: announcement.description,
logoUrl: announcement.logoUrl,
publicKey: announcement.publicKey,
softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount,
postCount: announcement.postCount,
capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(),
};
const { isNew } = await upsertSwarmNode(nodeInfo, 'announcement');
console.log(`[Swarm] ${isNew ? 'New' : 'Known'} node announced: ${announcement.domain}`);
// Respond with our own info
const ourAnnouncement = await buildAnnouncement();
return NextResponse.json({
domain: ourAnnouncement.domain,
name: ourAnnouncement.name,
description: ourAnnouncement.description,
logoUrl: ourAnnouncement.logoUrl,
publicKey: ourAnnouncement.publicKey,
softwareVersion: ourAnnouncement.softwareVersion,
userCount: ourAnnouncement.userCount,
postCount: ourAnnouncement.postCount,
capabilities: ourAnnouncement.capabilities,
lastSeenAt: new Date().toISOString(),
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid announcement payload', details: error.issues },
{ status: 400 }
);
}
console.error('Swarm announce error:', error);
return NextResponse.json(
{ error: 'Failed to process announcement' },
{ status: 500 }
);
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Swarm Gossip Endpoint
*
* POST: Exchange node and handle information with other nodes
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { processGossip } from '@/lib/swarm/gossip';
import { markNodeSuccess } from '@/lib/swarm/registry';
import type { SwarmGossipPayload } from '@/lib/swarm/types';
const handleSchema = z.object({
handle: z.string(),
did: z.string(),
nodeDomain: z.string(),
updatedAt: z.string().optional(),
});
const nodeInfoSchema = z.object({
domain: z.string(),
name: z.string().optional(),
description: z.string().optional(),
logoUrl: z.string().optional(),
publicKey: z.string().optional(),
softwareVersion: z.string().optional(),
userCount: z.number().optional(),
postCount: z.number().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search'])).optional(),
lastSeenAt: z.string().optional(),
});
const gossipPayloadSchema = z.object({
sender: z.string().min(1),
nodes: z.array(nodeInfoSchema),
handles: z.array(handleSchema).optional(),
timestamp: z.string(),
since: z.string().optional(),
});
/**
* POST /api/swarm/gossip
*
* Receives gossip from another node and responds with our own data.
* This is the core of the epidemic protocol - nodes exchange what they know.
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const payload = gossipPayloadSchema.parse(body) as SwarmGossipPayload;
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't process gossip from ourselves
if (payload.sender === ourDomain) {
return NextResponse.json(
{ error: 'Cannot gossip with self' },
{ status: 400 }
);
}
console.log(`[Swarm] Gossip from ${payload.sender}: ${payload.nodes.length} nodes, ${payload.handles?.length || 0} handles`);
// Process the incoming gossip and build our response
const response = await processGossip(payload);
// Mark the sender as successfully contacted
await markNodeSuccess(payload.sender);
console.log(`[Swarm] Gossip response to ${payload.sender}: ${response.nodes.length} nodes, ${response.handles?.length || 0} handles`);
return NextResponse.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid gossip payload', details: error.issues },
{ status: 400 }
);
}
console.error('Swarm gossip error:', error);
return NextResponse.json(
{ error: 'Failed to process gossip' },
{ status: 500 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Swarm Info Endpoint
*
* GET: Returns this node's public swarm information
*/
import { NextResponse } from 'next/server';
import { buildAnnouncement } from '@/lib/swarm/discovery';
/**
* GET /api/swarm/info
*
* Returns this node's public information for the swarm.
* Used by other nodes during discovery.
*/
export async function GET() {
try {
const announcement = await buildAnnouncement();
return NextResponse.json({
domain: announcement.domain,
name: announcement.name,
description: announcement.description,
logoUrl: announcement.logoUrl,
publicKey: announcement.publicKey,
softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount,
postCount: announcement.postCount,
capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(),
});
} catch (error) {
console.error('Swarm info error:', error);
return NextResponse.json(
{ error: 'Failed to get node info' },
{ status: 500 }
);
}
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Swarm Nodes Endpoint
*
* GET: List all known nodes in the swarm
* POST: Trigger discovery/sync operations (admin only)
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAdmin } from '@/lib/auth/admin';
import {
getActiveSwarmNodes,
getSwarmStats,
addSeedNode,
} from '@/lib/swarm/registry';
import {
announceToSeeds,
announceToNode,
discoverNode,
} from '@/lib/swarm/discovery';
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
/**
* GET /api/swarm/nodes
*
* Returns list of known swarm nodes and stats.
* Public endpoint - anyone can see the swarm.
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
const includeStats = searchParams.get('stats') === 'true';
const nodes = await getActiveSwarmNodes(limit);
const response: {
nodes: typeof nodes;
stats?: Awaited<ReturnType<typeof getSwarmStats>>;
} = { nodes };
if (includeStats) {
response.stats = await getSwarmStats();
}
return NextResponse.json(response);
} catch (error) {
console.error('Swarm nodes error:', error);
return NextResponse.json(
{ error: 'Failed to fetch swarm nodes' },
{ status: 500 }
);
}
}
const actionSchema = z.object({
action: z.enum(['announce', 'discover', 'gossip', 'addSeed']),
domain: z.string().optional(),
priority: z.number().optional(),
});
/**
* POST /api/swarm/nodes
*
* Admin-only endpoint to trigger swarm operations:
* - announce: Announce to seeds or a specific node
* - discover: Discover a specific node
* - gossip: Run a gossip round or gossip with specific node
* - addSeed: Add a new seed node
*/
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const { action, domain, priority } = actionSchema.parse(body);
switch (action) {
case 'announce': {
if (domain) {
const result = await announceToNode(domain);
return NextResponse.json({ action, domain, ...result });
} else {
const result = await announceToSeeds();
return NextResponse.json({ action, ...result });
}
}
case 'discover': {
if (!domain) {
return NextResponse.json(
{ error: 'Domain required for discover action' },
{ status: 400 }
);
}
const result = await discoverNode(domain);
return NextResponse.json({ action, domain, ...result });
}
case 'gossip': {
if (domain) {
const result = await gossipToNode(domain);
return NextResponse.json({ action, domain, ...result });
} else {
const result = await runGossipRound();
return NextResponse.json({ action, ...result });
}
}
case 'addSeed': {
if (!domain) {
return NextResponse.json(
{ error: 'Domain required for addSeed action' },
{ status: 400 }
);
}
await addSeedNode(domain, priority);
return NextResponse.json({ action, domain, success: true });
}
default:
return NextResponse.json(
{ error: 'Unknown action' },
{ status: 400 }
);
}
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid payload', details: error.issues },
{ status: 400 }
);
}
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Swarm nodes POST error:', error);
return NextResponse.json(
{ error: 'Failed to execute swarm action' },
{ status: 500 }
);
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Swarm Timeline Endpoint
*
* GET: Returns recent public posts from this node for the swarm timeline
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { eq, desc, and, isNull } from 'drizzle-orm';
export interface SwarmPost {
id: string;
content: string;
createdAt: string;
author: {
handle: string;
displayName: string;
avatarUrl?: string;
isNsfw: boolean;
};
nodeDomain: string;
nodeIsNsfw: boolean;
isNsfw: boolean;
likeCount: number;
repostCount: number;
replyCount: number;
mediaUrls?: string[];
}
/**
* GET /api/swarm/timeline
*
* Returns recent public posts from this node.
* Used by other nodes to build the swarm-wide timeline.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
if (!db) {
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Get node NSFW status
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
});
const nodeIsNsfw = node?.isNsfw ?? false;
// Get recent public posts (not replies, local users only, not removed)
const recentPosts = await db
.select({
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
authorIsNsfw: users.isNsfw,
authorNodeId: users.nodeId,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
isNull(posts.replyToId), // Not a reply
eq(posts.isRemoved, false) // Not removed
)
)
.orderBy(desc(posts.createdAt))
.limit(limit);
// Fetch media for each post
const swarmPosts: SwarmPost[] = [];
for (const post of recentPosts) {
const postMedia = await db
.select({ url: media.url })
.from(media)
.where(eq(media.postId, post.id));
swarmPosts.push({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: post.authorHandle,
displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw,
},
nodeDomain,
nodeIsNsfw,
isNsfw: post.isNsfw || post.authorIsNsfw || nodeIsNsfw, // Cascade NSFW flag
likeCount: post.likesCount,
repostCount: post.repostsCount,
replyCount: post.repliesCount,
mediaUrls: postMedia.length > 0 ? postMedia.map(m => m.url) : undefined,
});
}
return NextResponse.json({
posts: swarmPosts,
nodeDomain,
nodeIsNsfw,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Swarm timeline error:', error);
return NextResponse.json(
{ error: 'Failed to fetch timeline' },
{ status: 500 }
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { users, blocks, follows } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
type RouteContext = { params: Promise<{ handle: string }> };
// GET - Check if blocked
export async function GET(req: NextRequest, context: RouteContext) {
try {
const currentUser = await requireAuth();
const { handle } = await context.params;
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const block = await db.query.blocks.findFirst({
where: and(
eq(blocks.userId, currentUser.id),
eq(blocks.blockedUserId, targetUser.id)
),
});
return NextResponse.json({ blocked: !!block });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Check block error:', error);
return NextResponse.json({ error: 'Failed to check block status' }, { status: 500 });
}
}
// POST - Block user
export async function POST(req: NextRequest, context: RouteContext) {
try {
const currentUser = await requireAuth();
const { handle } = await context.params;
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.id === currentUser.id) {
return NextResponse.json({ error: 'Cannot block yourself' }, { status: 400 });
}
// Check if already blocked
const existing = await db.query.blocks.findFirst({
where: and(
eq(blocks.userId, currentUser.id),
eq(blocks.blockedUserId, targetUser.id)
),
});
if (existing) {
return NextResponse.json({ blocked: true });
}
// Create block
await db.insert(blocks).values({
userId: currentUser.id,
blockedUserId: targetUser.id,
});
// Remove any follows between the users
await db.delete(follows).where(
and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
)
);
await db.delete(follows).where(
and(
eq(follows.followerId, targetUser.id),
eq(follows.followingId, currentUser.id)
)
);
return NextResponse.json({ blocked: true });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Block user error:', error);
return NextResponse.json({ error: 'Failed to block user' }, { status: 500 });
}
}
// DELETE - Unblock user
export async function DELETE(req: NextRequest, context: RouteContext) {
try {
const currentUser = await requireAuth();
const { handle } = await context.params;
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
await db.delete(blocks).where(
and(
eq(blocks.userId, currentUser.id),
eq(blocks.blockedUserId, targetUser.id)
)
);
return NextResponse.json({ blocked: false });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error('Unblock user error:', error);
return NextResponse.json({ error: 'Failed to unblock user' }, { status: 500 });
}
}
+80 -25
View File
@@ -6,7 +6,7 @@ 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, Globe, Server } from 'lucide-react'; import { Bot, Network, Server } from 'lucide-react';
interface User { interface User {
id: string; id: string;
@@ -58,11 +58,28 @@ function UserCard({ user }: { user: User }) {
); );
} }
interface SwarmPost {
id: string;
content: string;
createdAt: string;
author: {
handle: string;
displayName: string;
avatarUrl?: string;
};
nodeDomain: string;
likeCount: number;
repostCount: number;
replyCount: number;
mediaUrls?: string[];
}
export default function ExplorePage() { export default function ExplorePage() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'node' | 'fediverse' | 'users' | 'search'>('node'); const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
const [nodePosts, setNodePosts] = useState<Post[]>([]); const [nodePosts, setNodePosts] = useState<Post[]>([]);
const [fediversePosts, setFediversePosts] = useState<Post[]>([]); const [swarmPosts, setSwarmPosts] = useState<SwarmPost[]>([]);
const [swarmSources, setSwarmSources] = useState<{ domain: string; postCount: number }[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
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);
@@ -87,23 +104,24 @@ export default function ExplorePage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// Load fediverse posts when tab changes // Load swarm posts when tab changes
if (activeTab === 'fediverse' && fediversePosts.length === 0) { if (activeTab === 'swarm' && swarmPosts.length === 0) {
const loadFediverse = async () => { const loadSwarm = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/posts?type=curated&limit=20'); const res = await fetch('/api/posts/swarm');
const data = await res.json(); const data = await res.json();
setFediversePosts(data.posts || []); setSwarmPosts(data.posts || []);
setSwarmSources(data.sources || []);
} catch { } catch {
setFediversePosts([]); setSwarmPosts([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadFediverse(); loadSwarm();
} }
}, [activeTab, fediversePosts.length]); }, [activeTab, swarmPosts.length]);
useEffect(() => { useEffect(() => {
// Load users when tab changes to users // Load users when tab changes to users
@@ -157,7 +175,7 @@ export default function ExplorePage() {
const handleDelete = (postId: string) => { const handleDelete = (postId: string) => {
setNodePosts(prev => prev.filter(p => p.id !== postId)); setNodePosts(prev => prev.filter(p => p.id !== postId));
setFediversePosts(prev => prev.filter(p => p.id !== postId)); setSwarmPosts(prev => prev.filter(p => p.id !== postId));
setSearchResults(prev => ({ setSearchResults(prev => ({
...prev, ...prev,
posts: prev.posts.filter(p => p.id !== postId) posts: prev.posts.filter(p => p.id !== postId)
@@ -188,11 +206,11 @@ export default function ExplorePage() {
<span>Node</span> <span>Node</span>
</button> </button>
<button <button
className={`explore-tab ${activeTab === 'fediverse' ? 'active' : ''}`} className={`explore-tab ${activeTab === 'swarm' ? 'active' : ''}`}
onClick={() => setActiveTab('fediverse')} onClick={() => setActiveTab('swarm')}
> >
<Globe size={18} /> <Network size={18} />
<span>Fediverse</span> <span>Swarm</span>
</button> </button>
<button <button
className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`} className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`}
@@ -238,25 +256,62 @@ export default function ExplorePage() {
) )
)} )}
{activeTab === 'fediverse' && ( {activeTab === 'swarm' && (
loading ? ( loading ? (
<div className="explore-loading">Loading fediverse posts...</div> <div className="explore-loading">Loading swarm posts...</div>
) : fediversePosts.length === 0 ? ( ) : swarmPosts.length === 0 ? (
<div className="explore-empty"> <div className="explore-empty">
<Globe size={24} /> <Network size={24} />
<p>No fediverse posts yet</p> <p>No swarm posts yet</p>
<p style={{ fontSize: '14px', opacity: 0.7, marginTop: '8px' }}>
Posts from other Synapsis nodes will appear here
</p>
</div> </div>
) : ( ) : (
<> <>
<div className="feed-meta card"> <div className="feed-meta card">
<div className="feed-meta-title">Fediverse feed</div> <div className="feed-meta-title">Swarm feed</div>
<div className="feed-meta-body"> <div className="feed-meta-body">
This feed shows posts from across the fediverse, including content from accounts that users on this node follow. Discover new voices and conversations from the wider federated network. Posts from across the Synapsis network. Currently showing posts from {swarmSources.filter(s => s.postCount > 0).length} node{swarmSources.filter(s => s.postCount > 0).length !== 1 ? 's' : ''}.
</div> </div>
</div> </div>
<div className="explore-posts"> <div className="explore-posts">
{fediversePosts.map((post) => ( {swarmPosts.map((post) => (
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} /> <div key={`${post.nodeDomain}:${post.id}`} className="swarm-post-wrapper">
<div className="swarm-post-card card">
<div className="swarm-post-header">
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div>
<div className="swarm-post-meta">
<span className="swarm-post-author">{post.author.displayName}</span>
<span className="swarm-post-handle">@{post.author.handle}@{post.nodeDomain}</span>
</div>
</div>
<div className="swarm-post-content">{post.content}</div>
{post.mediaUrls && post.mediaUrls.length > 0 && (
<div className="swarm-post-media">
{post.mediaUrls.map((url, i) => (
<img key={i} src={url} alt="" />
))}
</div>
)}
<div className="swarm-post-footer">
<span className="swarm-post-time">
{new Date(post.createdAt).toLocaleString()}
</span>
<span className="swarm-post-stats">
{post.likeCount > 0 && `${post.likeCount} likes`}
{post.likeCount > 0 && post.repostCount > 0 && ' · '}
{post.repostCount > 0 && `${post.repostCount} reposts`}
</span>
</div>
</div>
</div>
))} ))}
</div> </div>
</> </>
+111
View File
@@ -467,6 +467,42 @@ a.btn-primary:visited {
margin-top: 12px; margin-top: 12px;
} }
.compose-footer-left {
display: flex;
align-items: center;
gap: 12px;
}
.compose-nsfw-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--foreground-tertiary);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: all 0.15s ease;
}
.compose-nsfw-toggle:hover {
background: var(--background-secondary);
color: var(--foreground-secondary);
}
.compose-nsfw-toggle input {
display: none;
}
.compose-nsfw-toggle input:checked + svg {
color: var(--warning);
}
.compose-nsfw-toggle input:checked ~ span {
color: var(--warning);
font-weight: 500;
}
.compose-actions { .compose-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1373,6 +1409,81 @@ a.btn-primary:visited {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
/* Swarm Post Styles */
.swarm-post-wrapper {
border-bottom: 1px solid var(--border);
}
.swarm-post-card {
padding: 16px;
border: none;
border-radius: 0;
background: transparent;
}
.swarm-post-card:hover {
background: var(--background-secondary);
}
.swarm-post-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.swarm-post-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.swarm-post-author {
font-weight: 600;
font-size: 15px;
color: var(--foreground);
}
.swarm-post-handle {
font-size: 13px;
color: var(--foreground-tertiary);
}
.swarm-post-content {
font-size: 15px;
line-height: 1.5;
color: var(--foreground);
white-space: pre-wrap;
word-break: break-word;
}
.swarm-post-media {
margin-top: 12px;
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.swarm-post-media img {
width: 100%;
border-radius: var(--radius-md);
max-height: 300px;
object-fit: cover;
}
.swarm-post-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
font-size: 13px;
color: var(--foreground-tertiary);
}
.swarm-post-stats {
color: var(--foreground-secondary);
}
/* Scrollbar Styling */ /* Scrollbar Styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
+2 -2
View File
@@ -55,11 +55,11 @@ export default function Home() {
loadFeed(feedType); loadFeed(feedType);
}, [feedType]); }, [feedType]);
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => { const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
const res = await fetch('/api/posts', { const res = await fetch('/api/posts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }), body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }),
}); });
if (res.ok) { if (res.ok) {
+380
View File
@@ -0,0 +1,380 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Eye, EyeOff, AlertTriangle, Check } from 'lucide-react';
interface NsfwSettings {
nsfwEnabled: boolean;
ageVerifiedAt: string | null;
isNsfw: boolean;
}
export default function ContentSettingsPage() {
const [settings, setSettings] = useState<NsfwSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showAgeModal, setShowAgeModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings/nsfw');
if (res.ok) {
const data = await res.json();
setSettings(data);
}
} catch {
setError('Failed to load settings');
} finally {
setLoading(false);
}
};
const handleToggleNsfw = async () => {
if (!settings) return;
// If enabling and not verified, show age modal
if (!settings.nsfwEnabled && !settings.ageVerifiedAt) {
setShowAgeModal(true);
return;
}
// Otherwise just toggle
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings/nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nsfwEnabled: !settings.nsfwEnabled }),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => prev ? { ...prev, nsfwEnabled: data.nsfwEnabled } : null);
setSuccess(data.nsfwEnabled ? 'NSFW content enabled' : 'NSFW content disabled');
setTimeout(() => setSuccess(null), 3000);
} else {
const data = await res.json();
setError(data.error || 'Failed to update');
}
} catch {
setError('Failed to update settings');
} finally {
setSaving(false);
}
};
const handleAgeConfirm = async () => {
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings/nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nsfwEnabled: true, confirmAge: true }),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => prev ? {
...prev,
nsfwEnabled: true,
ageVerifiedAt: data.ageVerifiedAt,
} : null);
setShowAgeModal(false);
setSuccess('NSFW content enabled');
setTimeout(() => setSuccess(null), 3000);
} else {
const data = await res.json();
setError(data.error || 'Failed to verify');
}
} catch {
setError('Failed to verify age');
} finally {
setSaving(false);
}
};
const handleToggleAccountNsfw = async () => {
if (!settings) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings/account-nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isNsfw: !settings.isNsfw }),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => prev ? { ...prev, isNsfw: data.isNsfw } : null);
setSuccess(data.isNsfw ? 'Account marked as NSFW' : 'Account unmarked as NSFW');
setTimeout(() => setSuccess(null), 3000);
} else {
const data = await res.json();
setError(data.error || 'Failed to update');
}
} catch {
setError('Failed to update settings');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Content Settings</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
NSFW and content visibility preferences
</p>
</div>
</header>
{error && (
<div style={{
padding: '12px 16px',
background: 'var(--error-muted)',
color: 'var(--error)',
borderRadius: 'var(--radius-md)',
marginBottom: '16px',
fontSize: '14px',
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: '12px 16px',
background: 'var(--success-muted)',
color: 'var(--success)',
borderRadius: 'var(--radius-md)',
marginBottom: '16px',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<Check size={16} />
{success}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* View NSFW Content */}
<div className="card" style={{ padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div style={{ flex: 1 }}>
<div style={{
fontWeight: 600,
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
{settings?.nsfwEnabled ? <Eye size={18} /> : <EyeOff size={18} />}
Show NSFW Content
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
{settings?.nsfwEnabled
? 'You can see posts marked as sensitive or from NSFW accounts/nodes.'
: 'NSFW content is hidden from your feeds and search results.'}
</div>
{settings?.ageVerifiedAt && (
<div style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
}}>
Age verified on {new Date(settings.ageVerifiedAt).toLocaleDateString()}
</div>
)}
</div>
<button
onClick={handleToggleNsfw}
disabled={saving}
className={`btn btn-sm ${settings?.nsfwEnabled ? 'btn-ghost' : 'btn-primary'}`}
>
{settings?.nsfwEnabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
{/* Mark Account as NSFW - only show if NSFW viewing is enabled */}
{settings?.nsfwEnabled && (
<div className="card" style={{ padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div style={{ flex: 1 }}>
<div style={{
fontWeight: 600,
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<AlertTriangle size={18} />
Mark My Account as NSFW
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
{settings?.isNsfw
? 'Your account is marked as NSFW. All your posts will be hidden from users who haven\'t enabled NSFW content.'
: 'Enable this if you regularly post adult or sensitive content. Your posts will only be visible to users who have enabled NSFW viewing.'}
</div>
</div>
<button
onClick={handleToggleAccountNsfw}
disabled={saving}
style={{
padding: '8px 16px',
borderRadius: 'var(--radius-md)',
border: settings?.isNsfw ? 'none' : '1px solid var(--border)',
background: settings?.isNsfw ? 'var(--error)' : 'var(--background-secondary)',
color: settings?.isNsfw ? 'white' : 'var(--foreground)',
fontWeight: 500,
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
}}
>
{settings?.isNsfw ? 'Remove' : 'Mark NSFW'}
</button>
</div>
</div>
)}
{/* Info box */}
<div style={{
padding: '16px',
background: 'var(--background-secondary)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}>
<div style={{
fontWeight: 600,
marginBottom: '8px',
fontSize: '14px',
}}>
How NSFW filtering works
</div>
<ul style={{
margin: 0,
paddingLeft: '20px',
color: 'var(--foreground-secondary)',
fontSize: '13px',
lineHeight: 1.6,
}}>
<li>Content is marked NSFW at three levels: post, account, or node</li>
<li>If any level is NSFW, the content is hidden from non-verified users</li>
<li>You can mark individual posts as NSFW when composing</li>
<li>NSFW content still syncs across the swarm, but is filtered at display time</li>
</ul>
</div>
</div>
{/* Age Verification Modal */}
{showAgeModal && (
<div style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '16px',
}}>
<div className="card" style={{
maxWidth: '400px',
width: '100%',
padding: '24px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
}}>
<AlertTriangle size={24} color="var(--warning)" />
<h2 style={{ fontSize: '18px', fontWeight: 600 }}>Age Verification</h2>
</div>
<p style={{
color: 'var(--foreground-secondary)',
fontSize: '14px',
marginBottom: '24px',
lineHeight: 1.6,
}}>
NSFW content may include adult themes, nudity, or other sensitive material.
By enabling this setting, you confirm that you are at least 18 years old.
</p>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end',
}}>
<button
onClick={() => setShowAgeModal(false)}
style={{
padding: '10px 20px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--foreground)',
fontWeight: 500,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleAgeConfirm}
disabled={saving}
className="btn btn-primary"
>
{saving ? 'Confirming...' : 'I am 18 or older'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { UserX, Globe, Trash2 } from 'lucide-react';
interface BlockedUser {
id: string;
handle: string;
displayName: string | null;
avatarUrl: string | null;
blockedAt: string;
}
interface MutedNode {
domain: string;
mutedAt: string;
}
export default function ModerationSettingsPage() {
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
const [mutedNodes, setMutedNodes] = useState<MutedNode[]>([]);
const [loading, setLoading] = useState(true);
const [newNodeDomain, setNewNodeDomain] = useState('');
const [addingNode, setAddingNode] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [blockedRes, mutedRes] = await Promise.all([
fetch('/api/settings/blocked-users'),
fetch('/api/settings/muted-nodes'),
]);
if (blockedRes.ok) {
const data = await blockedRes.json();
setBlockedUsers(data.blockedUsers || []);
}
if (mutedRes.ok) {
const data = await mutedRes.json();
setMutedNodes(data.mutedNodes || []);
}
} catch (error) {
console.error('Failed to load moderation settings:', error);
} finally {
setLoading(false);
}
};
const handleUnblock = async (userId: string) => {
try {
const res = await fetch(`/api/settings/blocked-users?userId=${userId}`, {
method: 'DELETE',
});
if (res.ok) {
setBlockedUsers(prev => prev.filter(u => u.id !== userId));
}
} catch (error) {
console.error('Failed to unblock user:', error);
}
};
const handleUnmuteNode = async (domain: string) => {
try {
const res = await fetch(`/api/settings/muted-nodes?domain=${encodeURIComponent(domain)}`, {
method: 'DELETE',
});
if (res.ok) {
setMutedNodes(prev => prev.filter(n => n.domain !== domain));
}
} catch (error) {
console.error('Failed to unmute node:', error);
}
};
const handleMuteNode = async (e: React.FormEvent) => {
e.preventDefault();
if (!newNodeDomain.trim() || addingNode) return;
setAddingNode(true);
try {
const res = await fetch('/api/settings/muted-nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: newNodeDomain.trim() }),
});
if (res.ok) {
const data = await res.json();
setMutedNodes(prev => [
{ domain: data.domain, mutedAt: new Date().toISOString() },
...prev.filter(n => n.domain !== data.domain),
]);
setNewNodeDomain('');
}
} catch (error) {
console.error('Failed to mute node:', error);
} finally {
setAddingNode(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Moderation</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage blocked users and muted nodes
</p>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{/* Blocked Users */}
<section>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}>
<UserX size={18} />
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>Blocked Users</h2>
<span style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
background: 'var(--background-secondary)',
padding: '2px 8px',
borderRadius: '10px',
}}>
{blockedUsers.length}
</span>
</div>
{blockedUsers.length === 0 ? (
<div className="card" style={{
padding: '24px',
textAlign: 'center',
color: 'var(--foreground-tertiary)',
}}>
You haven't blocked anyone
</div>
) : (
<div className="card" style={{ overflow: 'hidden' }}>
{blockedUsers.map((user, i) => (
<div
key={user.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < blockedUsers.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<Link
href={`/@${user.handle}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
color: 'var(--foreground)',
textDecoration: 'none',
}}
>
<img
src={user.avatarUrl || '/default-avatar.png'}
alt=""
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
objectFit: 'cover',
}}
/>
<div>
<div style={{ fontWeight: 500 }}>
{user.displayName || user.handle}
</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
@{user.handle}
</div>
</div>
</Link>
<button
onClick={() => handleUnblock(user.id)}
className="btn btn-ghost btn-sm"
>
Unblock
</button>
</div>
))}
</div>
)}
</section>
{/* Muted Nodes */}
<section>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}>
<Globe size={18} />
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>Muted Nodes</h2>
<span style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
background: 'var(--background-secondary)',
padding: '2px 8px',
borderRadius: '10px',
}}>
{mutedNodes.length}
</span>
</div>
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginBottom: '12px',
}}>
Posts from muted nodes won't appear in your feeds or search results.
</p>
<form onSubmit={handleMuteNode} style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
className="input"
placeholder="node.example.com"
value={newNodeDomain}
onChange={(e) => setNewNodeDomain(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-primary"
disabled={!newNodeDomain.trim() || addingNode}
>
{addingNode ? 'Adding...' : 'Mute'}
</button>
</div>
</form>
{mutedNodes.length === 0 ? (
<div className="card" style={{
padding: '24px',
textAlign: 'center',
color: 'var(--foreground-tertiary)',
}}>
No muted nodes
</div>
) : (
<div className="card" style={{ overflow: 'hidden' }}>
{mutedNodes.map((node, i) => (
<div
key={node.domain}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < mutedNodes.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div>
<div style={{ fontWeight: 500 }}>{node.domain}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
Muted {new Date(node.mutedAt).toLocaleDateString()}
</div>
</div>
<button
onClick={() => handleUnmuteNode(node.domain)}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
+33 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons'; import { ArrowLeftIcon } from '@/components/Icons';
import { Rocket, Shield, Bell, Bot } from 'lucide-react'; import { Rocket, Shield, Bell, Bot, Eye, UserX } from 'lucide-react';
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
@@ -41,6 +41,38 @@ export default function SettingsPage() {
</div> </div>
</Link> </Link>
<Link href="/settings/content" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Eye size={18} />
Content Settings
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
NSFW preferences and content visibility
</div>
</Link>
<Link href="/settings/moderation" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserX size={18} />
Moderation
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Blocked users and muted nodes
</div>
</Link>
<Link href="/settings/migration" className="card" style={{ <Link href="/settings/migration" className="card" style={{
display: 'block', display: 'block',
padding: '20px', padding: '20px',
+31 -3
View File
@@ -3,12 +3,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import AutoTextarea from '@/components/AutoTextarea'; import AutoTextarea from '@/components/AutoTextarea';
import { Post, Attachment } from '@/lib/types'; import { Post, Attachment } from '@/lib/types';
import { ImageIcon } from 'lucide-react'; import { ImageIcon, AlertTriangle } from 'lucide-react';
import { VideoEmbed } from '@/components/VideoEmbed'; import { VideoEmbed } from '@/components/VideoEmbed';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
interface ComposeProps { interface ComposeProps {
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => void; onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void;
replyingTo?: Post | null; replyingTo?: Post | null;
onCancelReply?: () => void; onCancelReply?: () => void;
placeholder?: string; placeholder?: string;
@@ -24,9 +24,23 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const [linkPreview, setLinkPreview] = useState<any>(null); const [linkPreview, setLinkPreview] = useState<any>(null);
const [fetchingPreview, setFetchingPreview] = useState(false); const [fetchingPreview, setFetchingPreview] = useState(false);
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null); const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
const [isNsfw, setIsNsfw] = useState(false);
const [canPostNsfw, setCanPostNsfw] = useState(false);
const maxLength = 400; const maxLength = 400;
const remaining = maxLength - content.length; const remaining = maxLength - content.length;
// Check if user can post NSFW content
useEffect(() => {
fetch('/api/settings/nsfw')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.nsfwEnabled) {
setCanPostNsfw(true);
}
})
.catch(() => {});
}, []);
// Detect URLs in content // Detect URLs in content
useEffect(() => { useEffect(() => {
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
@@ -62,11 +76,12 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const handleSubmit = async () => { const handleSubmit = async () => {
if (!content.trim() || isPosting || isUploading) return; if (!content.trim() || isPosting || isUploading) return;
setIsPosting(true); setIsPosting(true);
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id); await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
setContent(''); setContent('');
setAttachments([]); setAttachments([]);
setLinkPreview(null); setLinkPreview(null);
setLastDetectedUrl(null); setLastDetectedUrl(null);
setIsNsfw(false);
setIsPosting(false); setIsPosting(false);
}; };
@@ -183,9 +198,22 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
<div className="compose-media-error">{uploadError}</div> <div className="compose-media-error">{uploadError}</div>
)} )}
<div className="compose-footer"> <div className="compose-footer">
<div className="compose-footer-left">
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}> <span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
{remaining} {remaining}
</span> </span>
{canPostNsfw && (
<label className="compose-nsfw-toggle" title="Mark as sensitive content">
<input
type="checkbox"
checked={isNsfw}
onChange={(e) => setIsNsfw(e.target.checked)}
/>
<AlertTriangle size={16} />
<span>NSFW</span>
</label>
)}
</div>
<div className="compose-actions"> <div className="compose-actions">
<label className="compose-media-button" title="Add media"> <label className="compose-media-button" title="Add media">
{isUploading ? '...' : <ImageIcon size={20} />} {isUploading ? '...' : <ImageIcon size={20} />}
+211 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons'; import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
import { Bot } from 'lucide-react'; import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
import { Post } from '@/lib/types'; import { Post } from '@/lib/types';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { useToast } from '@/lib/contexts/ToastContext'; import { useToast } from '@/lib/contexts/ToastContext';
@@ -33,16 +33,18 @@ interface PostCardProps {
onRepost?: (id: string, currentReposted: boolean) => void; onRepost?: (id: string, currentReposted: boolean) => void;
onComment?: (post: Post) => void; onComment?: (post: Post) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onHide?: (id: string) => void; // Called when post should be hidden (block/mute)
isDetail?: boolean; isDetail?: boolean;
} }
export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail }: PostCardProps) { export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail }: PostCardProps) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const [liked, setLiked] = useState(post.isLiked || false); const [liked, setLiked] = useState(post.isLiked || false);
const [reposted, setReposted] = useState(post.isReposted || false); const [reposted, setReposted] = useState(post.isReposted || false);
const [reporting, setReporting] = useState(false); const [reporting, setReporting] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
// Sync state if post changes (e.g. after a re-render from parent) // Sync state if post changes (e.g. after a re-render from parent)
useEffect(() => { useEffect(() => {
@@ -150,6 +152,95 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
} }
}; };
const handleBlockUser = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
showToast('Please log in to block users', 'error');
return;
}
try {
const res = await fetch(`/api/users/${post.author.handle}/block`, {
method: 'POST',
});
if (res.ok) {
showToast(`Blocked @${post.author.handle}`, 'success');
onHide?.(post.id);
} else {
showToast('Failed to block user', 'error');
}
} catch {
showToast('Failed to block user', 'error');
}
};
const handleMuteUser = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
showToast('Please log in to mute users', 'error');
return;
}
// For now, muting a user is the same as blocking but with different messaging
// Could be expanded to just hide posts without breaking follows
try {
const res = await fetch(`/api/users/${post.author.handle}/block`, {
method: 'POST',
});
if (res.ok) {
showToast(`Muted @${post.author.handle}`, 'success');
onHide?.(post.id);
} else {
showToast('Failed to mute user', 'error');
}
} catch {
showToast('Failed to mute user', 'error');
}
};
const handleMuteNode = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
showToast('Please log in to mute nodes', 'error');
return;
}
// Extract node domain from the post
const nodeDomain = post.nodeDomain || (post.author.handle.includes('@')
? post.author.handle.split('@')[1]
: null);
if (!nodeDomain) {
showToast('Cannot determine node for this post', 'error');
return;
}
try {
const res = await fetch('/api/settings/muted-nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: nodeDomain }),
});
if (res.ok) {
showToast(`Muted node: ${nodeDomain}`, 'success');
onHide?.(post.id);
} else {
showToast('Failed to mute node', 'error');
}
} catch {
showToast('Failed to mute node', 'error');
}
};
const postUrl = `/${post.author.handle}/posts/${post.id}`; const postUrl = `/${post.author.handle}/posts/${post.id}`;
// Decode HTML entities from federated posts (e.g., &amp;rsquo; -> ') // Decode HTML entities from federated posts (e.g., &amp;rsquo; -> ')
@@ -290,6 +381,124 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
</div> </div>
<span className="post-time">{formatFullHandle(post.author.handle)} · {formatTime(post.createdAt)}</span> <span className="post-time">{formatFullHandle(post.author.handle)} · {formatTime(post.createdAt)}</span>
</div> </div>
{currentUser && currentUser.id !== post.author.id && (
<div style={{ position: 'relative', marginLeft: 'auto' }}>
<button
className="post-menu-btn"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(!showMenu);
}}
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: 'var(--foreground-tertiary)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<MoreHorizontal size={18} />
</button>
{showMenu && (
<>
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99,
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
}}
/>
<div
className="post-menu-dropdown"
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: '4px',
background: 'var(--background-secondary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
minWidth: '180px',
zIndex: 100,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<button
onClick={handleMuteUser}
style={{
width: '100%',
padding: '10px 14px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'var(--foreground)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<VolumeX size={16} />
Mute
</button>
<button
onClick={handleBlockUser}
style={{
width: '100%',
padding: '10px 14px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'var(--foreground)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<UserX size={16} />
Block
</button>
{(post.nodeDomain || post.author.handle.includes('@')) && (
<button
onClick={handleMuteNode}
style={{
width: '100%',
padding: '10px 14px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'var(--foreground)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
borderTop: '1px solid var(--border)',
}}
>
<Globe size={16} />
Mute node
</button>
)}
</div>
</>
)}
</div>
)}
</div> </div>
{post.replyTo && ( {post.replyTo && (
+158
View File
@@ -16,6 +16,8 @@ export const nodes = pgTable('nodes', {
logoUrl: text('logo_url'), logoUrl: text('logo_url'),
accentColor: text('accent_color').default('#FFFFFF'), accentColor: text('accent_color').default('#FFFFFF'),
publicKey: text('public_key'), publicKey: text('public_key'),
// NSFW settings
isNsfw: boolean('is_nsfw').default(false).notNull(), // Entire node is NSFW
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),
}); });
@@ -40,6 +42,10 @@ export const users = pgTable('users', {
// Bot-related fields // Bot-related fields
isBot: boolean('is_bot').default(false).notNull(), isBot: boolean('is_bot').default(false).notNull(),
botOwnerId: uuid('bot_owner_id'), botOwnerId: uuid('bot_owner_id'),
// NSFW settings
isNsfw: boolean('is_nsfw').default(false).notNull(), // Account produces NSFW content
nsfwEnabled: boolean('nsfw_enabled').default(false).notNull(), // User wants to see NSFW content
ageVerifiedAt: timestamp('age_verified_at'), // When user confirmed 18+
// Moderation fields // Moderation fields
isSuspended: boolean('is_suspended').default(false).notNull(), isSuspended: boolean('is_suspended').default(false).notNull(),
suspensionReason: text('suspension_reason'), suspensionReason: text('suspension_reason'),
@@ -64,6 +70,7 @@ export const users = pgTable('users', {
index('users_silenced_idx').on(table.isSilenced), index('users_silenced_idx').on(table.isSilenced),
index('users_is_bot_idx').on(table.isBot), index('users_is_bot_idx').on(table.isBot),
index('users_bot_owner_idx').on(table.botOwnerId), index('users_bot_owner_idx').on(table.botOwnerId),
index('users_nsfw_idx').on(table.isNsfw),
foreignKey({ foreignKey({
columns: [table.botOwnerId], columns: [table.botOwnerId],
foreignColumns: [table.id], foreignColumns: [table.id],
@@ -101,6 +108,9 @@ export const posts = pgTable('posts', {
likesCount: integer('likes_count').default(0).notNull(), likesCount: integer('likes_count').default(0).notNull(),
repostsCount: integer('reposts_count').default(0).notNull(), repostsCount: integer('reposts_count').default(0).notNull(),
repliesCount: integer('replies_count').default(0).notNull(), repliesCount: integer('replies_count').default(0).notNull(),
// NSFW
isNsfw: boolean('is_nsfw').default(false).notNull(), // This specific post is NSFW
// Moderation
isRemoved: boolean('is_removed').default(false).notNull(), isRemoved: boolean('is_removed').default(false).notNull(),
removedAt: timestamp('removed_at'), removedAt: timestamp('removed_at'),
removedBy: uuid('removed_by').references(() => users.id), removedBy: uuid('removed_by').references(() => users.id),
@@ -121,6 +131,7 @@ export const posts = pgTable('posts', {
index('posts_created_at_idx').on(table.createdAt), index('posts_created_at_idx').on(table.createdAt),
index('posts_reply_to_idx').on(table.replyToId), index('posts_reply_to_idx').on(table.replyToId),
index('posts_removed_idx').on(table.isRemoved), index('posts_removed_idx').on(table.isRemoved),
index('posts_nsfw_idx').on(table.isNsfw),
]); ]);
export const postsRelations = relations(posts, ({ one, many }) => ({ export const postsRelations = relations(posts, ({ one, many }) => ({
@@ -386,8 +397,20 @@ export const blocks = pgTable('blocks', {
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [ }, (table) => [
index('blocks_user_idx').on(table.userId), index('blocks_user_idx').on(table.userId),
index('blocks_blocked_user_idx').on(table.blockedUserId),
]); ]);
export const blocksRelations = relations(blocks, ({ one }) => ({
user: one(users, {
fields: [blocks.userId],
references: [users.id],
}),
blockedUser: one(users, {
fields: [blocks.blockedUserId],
references: [users.id],
}),
}));
export const mutes = pgTable('mutes', { export const mutes = pgTable('mutes', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
@@ -395,8 +418,38 @@ export const mutes = pgTable('mutes', {
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [ }, (table) => [
index('mutes_user_idx').on(table.userId), index('mutes_user_idx').on(table.userId),
index('mutes_muted_user_idx').on(table.mutedUserId),
]); ]);
export const mutesRelations = relations(mutes, ({ one }) => ({
user: one(users, {
fields: [mutes.userId],
references: [users.id],
}),
mutedUser: one(users, {
fields: [mutes.mutedUserId],
references: [users.id],
}),
}));
// Muted nodes - hide all content from specific swarm nodes
export const mutedNodes = pgTable('muted_nodes', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
nodeDomain: text('node_domain').notNull(), // Domain of the muted node
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('muted_nodes_user_idx').on(table.userId),
index('muted_nodes_domain_idx').on(table.nodeDomain),
]);
export const mutedNodesRelations = relations(mutedNodes, ({ one }) => ({
user: one(users, {
fields: [mutedNodes.userId],
references: [users.id],
}),
}));
// ============================================ // ============================================
// REPORTS (moderation) // REPORTS (moderation)
// ============================================ // ============================================
@@ -658,3 +711,108 @@ export const botRateLimitsRelations = relations(botRateLimits, ({ one }) => ({
references: [bots.id], references: [bots.id],
}), }),
})); }));
// ============================================
// SWARM - Node Discovery Network
// ============================================
/**
* Discovered nodes in the swarm network.
* Tracks all known Synapsis nodes discovered through gossip or seed nodes.
*/
export const swarmNodes = pgTable('swarm_nodes', {
id: uuid('id').primaryKey().defaultRandom(),
domain: text('domain').notNull().unique(),
// Node metadata (fetched from remote)
name: text('name'),
description: text('description'),
logoUrl: text('logo_url'),
publicKey: text('public_key'),
softwareVersion: text('software_version'),
// Stats (updated periodically)
userCount: integer('user_count'),
postCount: integer('post_count'),
// NSFW flag (synced from remote node)
isNsfw: boolean('is_nsfw').default(false).notNull(),
// Discovery metadata
discoveredVia: text('discovered_via'), // Domain of node that told us about this one
discoveredAt: timestamp('discovered_at').defaultNow().notNull(),
// Health tracking
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
lastSyncAt: timestamp('last_sync_at'),
consecutiveFailures: integer('consecutive_failures').default(0).notNull(),
isActive: boolean('is_active').default(true).notNull(),
// Trust/reputation (for future spam prevention)
trustScore: integer('trust_score').default(50).notNull(), // 0-100
// Capabilities
capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"]
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
index('swarm_nodes_domain_idx').on(table.domain),
index('swarm_nodes_active_idx').on(table.isActive),
index('swarm_nodes_last_seen_idx').on(table.lastSeenAt),
index('swarm_nodes_trust_idx').on(table.trustScore),
index('swarm_nodes_nsfw_idx').on(table.isNsfw),
]);
/**
* Seed nodes - well-known entry points to the swarm.
* These are the bootstrap nodes that new nodes contact first.
*/
export const swarmSeeds = pgTable('swarm_seeds', {
id: uuid('id').primaryKey().defaultRandom(),
domain: text('domain').notNull().unique(),
// Priority for connection order (lower = higher priority)
priority: integer('priority').default(100).notNull(),
// Whether this seed is enabled
isEnabled: boolean('is_enabled').default(true).notNull(),
// Health tracking
lastContactAt: timestamp('last_contact_at'),
consecutiveFailures: integer('consecutive_failures').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('swarm_seeds_enabled_idx').on(table.isEnabled),
index('swarm_seeds_priority_idx').on(table.priority),
]);
/**
* Swarm sync log - tracks gossip exchanges between nodes.
*/
export const swarmSyncLog = pgTable('swarm_sync_log', {
id: uuid('id').primaryKey().defaultRandom(),
// Which node we synced with
remoteDomain: text('remote_domain').notNull(),
// Direction: 'push' (we sent) or 'pull' (we received)
direction: text('direction').notNull(),
// What was synced
nodesReceived: integer('nodes_received').default(0).notNull(),
nodesSent: integer('nodes_sent').default(0).notNull(),
handlesReceived: integer('handles_received').default(0).notNull(),
handlesSent: integer('handles_sent').default(0).notNull(),
// Result
success: boolean('success').notNull(),
errorMessage: text('error_message'),
durationMs: integer('duration_ms'),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('swarm_sync_log_remote_idx').on(table.remoteDomain),
index('swarm_sync_log_created_idx').on(table.createdAt),
]);
+19
View File
@@ -0,0 +1,19 @@
/**
* Next.js Instrumentation
*
* This file runs when the Next.js server starts.
* We use it to initialize background tasks like:
* - Swarm announcement (on startup)
* - Bot cron (every 1 minute)
* - Swarm gossip (every 5 minutes)
*
* This eliminates the need for a separate cron process.
*/
export async function register() {
// Only run on the server (not during build or in edge runtime)
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startBackgroundTasks } = await import('@/lib/background/scheduler');
startBackgroundTasks();
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Background Task Scheduler
*
* Runs periodic tasks within the Next.js process:
* - Bot scheduling (every 1 minute)
* - Swarm gossip (every 5 minutes)
* - Swarm announcement (on startup)
*/
import { processScheduledPosts } from '@/lib/bots/scheduler';
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
import { runGossipRound } from '@/lib/swarm/gossip';
import { announceToSeeds } from '@/lib/swarm/discovery';
import { getSwarmStats } from '@/lib/swarm/registry';
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const STARTUP_DELAY_MS = 10 * 1000; // Wait 10s for server to be ready
let isStarted = false;
function log(category: string, message: string, data?: unknown) {
const timestamp = new Date().toISOString();
if (data) {
console.log(`[${timestamp}] [${category}] ${message}`, JSON.stringify(data, null, 2));
} else {
console.log(`[${timestamp}] [${category}] ${message}`);
}
}
async function runBotTasks() {
try {
const scheduledResult = await processScheduledPosts();
const autonomousResult = await processAllAutonomousBots();
const posted = autonomousResult.filter(r => r.result.posted).length;
if (scheduledResult.processed > 0 || posted > 0) {
log('BOTS', `Processed ${scheduledResult.processed} scheduled, ${posted} autonomous posts`);
}
} catch (error) {
log('BOTS', `Error: ${error}`);
}
}
async function runSwarmGossip() {
try {
const result = await runGossipRound();
if (result.contacted > 0) {
log('SWARM', `Gossip: contacted ${result.contacted}, successful ${result.successful}, received ${result.totalNodesReceived} nodes`);
}
} catch (error) {
log('SWARM', `Gossip error: ${error}`);
}
}
async function announceToSwarm() {
try {
const result = await announceToSeeds();
log('SWARM', `Announced to seeds: ${result.successful.length} successful, ${result.failed.length} failed`);
const stats = await getSwarmStats();
log('SWARM', `Network: ${stats.activeNodes} active nodes, ${stats.totalUsers} users, ${stats.totalPosts} posts`);
} catch (error) {
log('SWARM', `Announcement error: ${error}`);
}
}
export function startBackgroundTasks() {
// Prevent double-start (Next.js can call register() multiple times in dev)
if (isStarted) return;
isStarted = true;
log('STARTUP', 'Background task scheduler starting...');
log('STARTUP', `Bot interval: ${BOT_INTERVAL_MS / 1000}s, Gossip interval: ${GOSSIP_INTERVAL_MS / 1000}s`);
// Wait for server to be fully ready before starting tasks
setTimeout(async () => {
log('STARTUP', 'Starting background tasks...');
// Announce to swarm on startup
await announceToSwarm();
// Run initial bot check
await runBotTasks();
// Schedule recurring tasks
setInterval(runBotTasks, BOT_INTERVAL_MS);
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
// First gossip after 30s (let announcement propagate)
setTimeout(runSwarmGossip, 30 * 1000);
log('STARTUP', 'Background tasks running');
}, STARTUP_DELAY_MS);
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Swarm Discovery
*
* Handles node discovery and announcement in the swarm network.
*/
import { db, nodes, users, posts } from '@/db';
import { eq, sql } from 'drizzle-orm';
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
/**
* Build this node's announcement payload
*/
export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let name = 'Synapsis Node';
let description: string | undefined;
let logoUrl: string | undefined;
let publicKey = '';
let userCount = 0;
let postCount = 0;
let isNsfw = false;
if (db) {
// Get node info
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
if (node) {
name = node.name;
description = node.description ?? undefined;
logoUrl = node.logoUrl ?? undefined;
publicKey = node.publicKey ?? '';
isNsfw = node.isNsfw;
}
// Get counts
const userResult = await db.select({ count: sql<number>`count(*)` }).from(users);
const postResult = await db.select({ count: sql<number>`count(*)` }).from(posts);
userCount = Number(userResult[0]?.count ?? 0);
postCount = Number(postResult[0]?.count ?? 0);
}
const capabilities: SwarmCapability[] = ['handles', 'gossip'];
return {
domain,
name,
description,
logoUrl,
publicKey,
softwareVersion: '0.1.0', // TODO: Get from package.json
userCount,
postCount,
capabilities,
isNsfw,
timestamp: new Date().toISOString(),
};
}
/**
* Announce this node to a remote node
*/
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
try {
const announcement = await buildAnnouncement();
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const url = `${baseUrl}/api/swarm/announce`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(announcement),
});
if (!response.ok) {
const error = await response.text();
await markNodeFailure(targetDomain);
return { success: false, error: `HTTP ${response.status}: ${error}` };
}
// The remote node should respond with their info
const remoteInfo = await response.json() as SwarmNodeInfo;
// Add/update the remote node in our registry
await upsertSwarmNode(remoteInfo, 'direct');
await markNodeSuccess(targetDomain);
return { success: true };
} catch (error) {
await markNodeFailure(targetDomain);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Announce to all seed nodes (bootstrap)
*/
export async function announceToSeeds(): Promise<{
successful: string[];
failed: { domain: string; error: string }[]
}> {
const seeds = await getSeedNodes();
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't announce to ourselves
const targetSeeds = seeds.filter(s => s !== ourDomain);
const successful: string[] = [];
const failed: { domain: string; error: string }[] = [];
for (const seed of targetSeeds) {
const result = await announceToNode(seed);
if (result.success) {
successful.push(seed);
} else {
failed.push({ domain: seed, error: result.error || 'Unknown error' });
}
}
return { successful, failed };
}
/**
* Fetch node info from a remote node
*/
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
try {
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
// Try the swarm endpoint first
let response = await fetch(`${baseUrl}/api/swarm/info`, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
// Fall back to standard node endpoint
response = await fetch(`${baseUrl}/api/node`, {
headers: { 'Accept': 'application/json' },
});
}
if (!response.ok) {
return null;
}
const data = await response.json();
return {
domain: data.domain || domain,
name: data.name,
description: data.description,
logoUrl: data.logoUrl,
publicKey: data.publicKey,
softwareVersion: data.softwareVersion,
userCount: data.userCount,
postCount: data.postCount,
capabilities: data.capabilities,
isNsfw: data.isNsfw,
};
} catch {
return null;
}
}
/**
* Discover a node and add it to the registry
*/
export async function discoverNode(
domain: string,
discoveredVia?: string
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't discover ourselves
if (domain === ourDomain) {
return { success: false, isNew: false, error: 'Cannot discover self' };
}
const info = await fetchNodeInfo(domain);
if (!info) {
return { success: false, isNew: false, error: 'Could not fetch node info' };
}
const result = await upsertSwarmNode(info, discoveredVia);
return { success: true, isNew: result.isNew };
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Swarm Gossip Protocol
*
* Implements epidemic-style gossip for node and handle propagation.
* Nodes periodically exchange their known nodes/handles with random peers.
*/
import { db, handleRegistry } from '@/db';
import { desc, gt } from 'drizzle-orm';
import type { SwarmGossipPayload, SwarmGossipResponse, SwarmSyncResult, SwarmNodeInfo } from './types';
import { SWARM_CONFIG } from './types';
import {
getNodesForGossip,
getActiveSwarmNodes,
getNodesSince,
upsertSwarmNodes,
markNodeSuccess,
markNodeFailure,
logSync,
} from './registry';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { buildAnnouncement } from './discovery';
/**
* Build a gossip payload to send to another node
*/
export async function buildGossipPayload(since?: string): Promise<SwarmGossipPayload> {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Get nodes to share
let nodes: SwarmNodeInfo[];
if (since) {
nodes = await getNodesSince(new Date(since), SWARM_CONFIG.maxNodesPerGossip);
} else {
nodes = await getActiveSwarmNodes(SWARM_CONFIG.maxNodesPerGossip);
}
// Include ourselves in the node list
const announcement = await buildAnnouncement();
const selfNode: SwarmNodeInfo = {
domain: announcement.domain,
name: announcement.name,
description: announcement.description,
logoUrl: announcement.logoUrl,
publicKey: announcement.publicKey,
softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount,
postCount: announcement.postCount,
capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(),
};
// Get handles to share
let handles: SwarmGossipPayload['handles'] = [];
if (db) {
const sinceDate = since ? new Date(since) : undefined;
const handleEntries = await db.query.handleRegistry.findMany({
where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
orderBy: [desc(handleRegistry.updatedAt)],
limit: SWARM_CONFIG.maxHandlesPerGossip,
});
handles = handleEntries.map(h => ({
handle: h.handle,
did: h.did,
nodeDomain: h.nodeDomain,
updatedAt: h.updatedAt?.toISOString(),
}));
}
return {
sender: ourDomain,
nodes: [selfNode, ...nodes],
handles,
timestamp: new Date().toISOString(),
since,
};
}
/**
* Process incoming gossip and return our response
*/
export async function processGossip(
payload: SwarmGossipPayload
): Promise<SwarmGossipResponse> {
const startTime = Date.now();
// Process incoming nodes
const nodeResult = await upsertSwarmNodes(payload.nodes, payload.sender);
// Process incoming handles
let handlesResult = { added: 0, updated: 0 };
if (payload.handles && payload.handles.length > 0) {
handlesResult = await upsertHandleEntries(payload.handles);
}
// Build our response with nodes/handles to share back
const responsePayload = await buildGossipPayload(payload.since);
return {
nodes: responsePayload.nodes,
handles: responsePayload.handles,
received: {
nodes: nodeResult.added + nodeResult.updated,
handles: handlesResult.added + handlesResult.updated,
},
};
}
/**
* Send gossip to a specific node
*/
export async function gossipToNode(
targetDomain: string,
since?: string
): Promise<SwarmSyncResult> {
const startTime = Date.now();
try {
const payload = await buildGossipPayload(since);
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const url = `${baseUrl}/api/swarm/gossip`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const durationMs = Date.now() - startTime;
if (!response.ok) {
const error = `HTTP ${response.status}`;
await markNodeFailure(targetDomain);
await logSync(targetDomain, 'push', {
success: false,
nodesReceived: 0,
nodesSent: payload.nodes.length,
handlesReceived: 0,
handlesSent: payload.handles?.length || 0,
error,
durationMs,
});
return {
success: false,
nodesReceived: 0,
nodesSent: payload.nodes.length,
handlesReceived: 0,
handlesSent: payload.handles?.length || 0,
error,
durationMs,
};
}
const gossipResponse = await response.json() as SwarmGossipResponse;
// Process the response (nodes and handles they sent back)
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain);
let handlesResult = { added: 0, updated: 0 };
if (gossipResponse.handles && gossipResponse.handles.length > 0) {
handlesResult = await upsertHandleEntries(gossipResponse.handles);
}
await markNodeSuccess(targetDomain);
const result: SwarmSyncResult = {
success: true,
nodesReceived: nodeResult.added + nodeResult.updated,
nodesSent: payload.nodes.length,
handlesReceived: handlesResult.added + handlesResult.updated,
handlesSent: payload.handles?.length || 0,
durationMs,
};
await logSync(targetDomain, 'push', result);
return result;
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await markNodeFailure(targetDomain);
const result: SwarmSyncResult = {
success: false,
nodesReceived: 0,
nodesSent: 0,
handlesReceived: 0,
handlesSent: 0,
error: errorMsg,
durationMs,
};
await logSync(targetDomain, 'push', result);
return result;
}
}
/**
* Run a gossip round - contact random nodes and exchange info
*/
export async function runGossipRound(): Promise<{
contacted: number;
successful: number;
totalNodesReceived: number;
totalHandlesReceived: number;
}> {
// Get random nodes to gossip with
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
let contacted = 0;
let successful = 0;
let totalNodesReceived = 0;
let totalHandlesReceived = 0;
for (const target of targets) {
contacted++;
const result = await gossipToNode(target.domain);
if (result.success) {
successful++;
totalNodesReceived += result.nodesReceived;
totalHandlesReceived += result.handlesReceived;
}
}
return {
contacted,
successful,
totalNodesReceived,
totalHandlesReceived,
};
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Swarm Module
*
* The Synapsis swarm is a decentralized network of nodes that discover
* and communicate with each other using a hybrid approach:
*
* 1. Seed nodes (like node.synapsis.social) provide initial bootstrap
* 2. Gossip protocol spreads node/handle information epidemically
* 3. Any node can discover the full network without central authority
*
* Usage:
* - On node startup: call announceToSeeds() to register with the network
* - Periodically: call runGossipRound() to exchange info with peers
* - On demand: call discoverNode() to add a specific node
*/
export * from './types';
export * from './registry';
export * from './discovery';
export * from './gossip';
+311
View File
@@ -0,0 +1,311 @@
/**
* Swarm Registry
*
* Manages the local registry of known swarm nodes.
*/
import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
/**
* Get or create a swarm node entry
*/
export async function upsertSwarmNode(
node: SwarmNodeInfo,
discoveredVia?: string
): Promise<{ isNew: boolean }> {
if (!db) {
return { isNew: false };
}
const existing = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, node.domain),
});
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
if (!existing) {
await db.insert(swarmNodes).values({
domain: node.domain,
name: node.name,
description: node.description,
logoUrl: node.logoUrl,
publicKey: node.publicKey,
softwareVersion: node.softwareVersion,
userCount: node.userCount,
postCount: node.postCount,
isNsfw: node.isNsfw ?? false,
discoveredVia,
capabilities,
lastSeenAt: node.lastSeenAt ? new Date(node.lastSeenAt) : new Date(),
});
return { isNew: true };
}
// Update existing node
await db.update(swarmNodes)
.set({
name: node.name ?? existing.name,
description: node.description ?? existing.description,
logoUrl: node.logoUrl ?? existing.logoUrl,
publicKey: node.publicKey ?? existing.publicKey,
softwareVersion: node.softwareVersion ?? existing.softwareVersion,
userCount: node.userCount ?? existing.userCount,
postCount: node.postCount ?? existing.postCount,
isNsfw: node.isNsfw ?? existing.isNsfw,
capabilities: capabilities ?? existing.capabilities,
lastSeenAt: new Date(),
consecutiveFailures: 0,
isActive: true,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, node.domain));
return { isNew: false };
}
/**
* Bulk upsert swarm nodes from gossip
*/
export async function upsertSwarmNodes(
nodes: SwarmNodeInfo[],
discoveredVia: string
): Promise<{ added: number; updated: number }> {
if (!db || nodes.length === 0) {
return { added: 0, updated: 0 };
}
let added = 0;
let updated = 0;
// Filter out our own domain
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
for (const node of filteredNodes) {
const result = await upsertSwarmNode(node, discoveredVia);
if (result.isNew) {
added++;
} else {
updated++;
}
}
return { added, updated };
}
/**
* Get all active swarm nodes
*/
export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]> {
if (!db) {
return [];
}
const nodes = await db.query.swarmNodes.findMany({
where: eq(swarmNodes.isActive, true),
orderBy: [desc(swarmNodes.lastSeenAt)],
limit,
});
return nodes.map(nodeToInfo);
}
/**
* Get nodes for gossip (random selection of active nodes)
*/
export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]> {
if (!db) {
return [];
}
// Get active nodes with decent trust scores, ordered randomly
const nodes = await db.query.swarmNodes.findMany({
where: and(
eq(swarmNodes.isActive, true),
gt(swarmNodes.trustScore, 20)
),
orderBy: sql`RANDOM()`,
limit: count,
});
return nodes.map(nodeToInfo);
}
/**
* Get nodes updated since a timestamp (for incremental sync)
*/
export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNodeInfo[]> {
if (!db) {
return [];
}
const nodes = await db.query.swarmNodes.findMany({
where: gt(swarmNodes.updatedAt, since),
orderBy: [desc(swarmNodes.updatedAt)],
limit,
});
return nodes.map(nodeToInfo);
}
/**
* Mark a node as having failed contact
*/
export async function markNodeFailure(domain: string): Promise<void> {
if (!db) return;
const node = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, domain),
});
if (!node) return;
const newFailures = node.consecutiveFailures + 1;
const newTrust = Math.max(
SWARM_CONFIG.minTrustScore,
node.trustScore + SWARM_CONFIG.trustScoreOnFailure
);
const isActive = newFailures < SWARM_CONFIG.maxConsecutiveFailures;
await db.update(swarmNodes)
.set({
consecutiveFailures: newFailures,
trustScore: newTrust,
isActive,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, domain));
}
/**
* Mark a node as successfully contacted
*/
export async function markNodeSuccess(domain: string): Promise<void> {
if (!db) return;
const node = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, domain),
});
if (!node) return;
const newTrust = Math.min(
SWARM_CONFIG.maxTrustScore,
node.trustScore + SWARM_CONFIG.trustScoreOnSuccess
);
await db.update(swarmNodes)
.set({
consecutiveFailures: 0,
trustScore: newTrust,
isActive: true,
lastSeenAt: new Date(),
lastSyncAt: new Date(),
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, domain));
}
/**
* Log a sync operation
*/
export async function logSync(
remoteDomain: string,
direction: 'push' | 'pull',
result: SwarmSyncResult
): Promise<void> {
if (!db) return;
await db.insert(swarmSyncLog).values({
remoteDomain,
direction,
nodesReceived: result.nodesReceived,
nodesSent: result.nodesSent,
handlesReceived: result.handlesReceived,
handlesSent: result.handlesSent,
success: result.success,
errorMessage: result.error,
durationMs: result.durationMs,
});
}
/**
* Get seed nodes (with fallback to defaults)
*/
export async function getSeedNodes(): Promise<string[]> {
if (!db) {
return [...DEFAULT_SEED_NODES];
}
const seeds = await db.query.swarmSeeds.findMany({
where: eq(swarmSeeds.isEnabled, true),
orderBy: [swarmSeeds.priority],
});
if (seeds.length === 0) {
return [...DEFAULT_SEED_NODES];
}
return seeds.map(s => s.domain);
}
/**
* Add a seed node
*/
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
if (!db) return;
await db.insert(swarmSeeds)
.values({ domain, priority })
.onConflictDoUpdate({
target: swarmSeeds.domain,
set: { priority, isEnabled: true },
});
}
/**
* Get swarm statistics
*/
export async function getSwarmStats() {
if (!db) {
return {
totalNodes: 0,
activeNodes: 0,
totalUsers: 0,
totalPosts: 0,
};
}
const allNodes = await db.query.swarmNodes.findMany();
const activeNodes = allNodes.filter(n => n.isActive);
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
return {
totalNodes: allNodes.length,
activeNodes: activeNodes.length,
totalUsers,
totalPosts,
};
}
// Helper to convert DB node to SwarmNodeInfo
function nodeToInfo(node: typeof swarmNodes.$inferSelect): SwarmNodeInfo {
return {
domain: node.domain,
name: node.name ?? undefined,
description: node.description ?? undefined,
logoUrl: node.logoUrl ?? undefined,
publicKey: node.publicKey ?? undefined,
softwareVersion: node.softwareVersion ?? undefined,
userCount: node.userCount ?? undefined,
postCount: node.postCount ?? undefined,
capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined,
isNsfw: node.isNsfw,
lastSeenAt: node.lastSeenAt.toISOString(),
};
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Swarm Timeline
*
* Fetches and aggregates posts from across the swarm network.
*/
import { getActiveSwarmNodes } from './registry';
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
interface TimelineResult {
posts: SwarmPost[];
sources: { domain: string; postCount: number; isNsfw?: boolean; error?: string }[];
fetchedAt: string;
}
interface TimelineOptions {
includeNsfw?: boolean; // Whether to include NSFW content
}
/**
* Fetch timeline from a single node
*/
async function fetchNodeTimeline(
domain: string,
limit: number = 20
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
try {
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return { posts: [], error: `HTTP ${response.status}` };
}
const data = await response.json();
return { posts: data.posts || [], nodeIsNsfw: data.nodeIsNsfw };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { posts: [], error: message };
}
}
/**
* Fetch aggregated timeline from the swarm
*
* Queries multiple nodes in parallel and merges results.
* Filters out NSFW content unless explicitly requested.
*/
export async function fetchSwarmTimeline(
maxNodes: number = 10,
postsPerNode: number = 10,
options: TimelineOptions = {}
): Promise<TimelineResult> {
const { includeNsfw = false } = options;
// Get active nodes to query
const nodes = await getActiveSwarmNodes(maxNodes);
// Always include our own posts
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Filter out NSFW nodes if not including NSFW content
const eligibleNodes = includeNsfw
? nodes
: nodes.filter(n => !n.isNsfw);
const nodesToQuery = [
ourDomain,
...eligibleNodes.map(n => n.domain).filter(d => d !== ourDomain)
].slice(0, maxNodes);
// Fetch from all nodes in parallel
const results = await Promise.all(
nodesToQuery.map(async (domain) => {
const result = await fetchNodeTimeline(domain, postsPerNode);
return {
domain,
...result,
};
})
);
// Collect all posts and track sources
const allPosts: SwarmPost[] = [];
const sources: TimelineResult['sources'] = [];
for (const result of results) {
sources.push({
domain: result.domain,
postCount: result.posts.length,
isNsfw: result.nodeIsNsfw,
error: result.error,
});
// Filter NSFW posts if not including NSFW
const filteredPosts = includeNsfw
? result.posts
: result.posts.filter(p => !p.isNsfw);
allPosts.push(...filteredPosts);
}
// Sort by createdAt descending and dedupe by id
const seen = new Set<string>();
const uniquePosts = allPosts
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.filter(post => {
const key = `${post.nodeDomain}:${post.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return {
posts: uniquePosts,
sources,
fetchedAt: new Date().toISOString(),
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Swarm Types
*
* Type definitions for the Synapsis swarm network.
*/
export interface SwarmNodeInfo {
domain: string;
name?: string;
description?: string;
logoUrl?: string;
publicKey?: string;
softwareVersion?: string;
userCount?: number;
postCount?: number;
capabilities?: SwarmCapability[];
isNsfw?: boolean;
lastSeenAt?: string;
}
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search';
export interface SwarmAnnouncement {
domain: string;
name: string;
description?: string;
logoUrl?: string;
publicKey: string;
softwareVersion: string;
userCount: number;
postCount: number;
capabilities: SwarmCapability[];
isNsfw: boolean;
timestamp: string;
signature?: string; // Signed with node's private key
}
export interface SwarmGossipPayload {
// The node sending this gossip
sender: string;
// Nodes this sender knows about
nodes: SwarmNodeInfo[];
// Optional: handles to sync (piggyback on gossip)
handles?: {
handle: string;
did: string;
nodeDomain: string;
updatedAt?: string;
}[];
// Timestamp for freshness
timestamp: string;
// Since parameter for incremental sync
since?: string;
}
export interface SwarmGossipResponse {
// Nodes we're sharing back
nodes: SwarmNodeInfo[];
// Handles we're sharing back
handles?: {
handle: string;
did: string;
nodeDomain: string;
updatedAt?: string;
}[];
// Stats about what we received
received: {
nodes: number;
handles: number;
};
}
export interface SwarmSyncResult {
success: boolean;
nodesReceived: number;
nodesSent: number;
handlesReceived: number;
handlesSent: number;
error?: string;
durationMs: number;
}
export interface SwarmStats {
totalNodes: number;
activeNodes: number;
totalUsers: number;
totalPosts: number;
lastUpdated: string;
}
// Default seed nodes for bootstrapping
export const DEFAULT_SEED_NODES = [
'node.synapsis.social',
] as const;
// Swarm configuration
export const SWARM_CONFIG = {
// How often to run gossip (in ms)
gossipIntervalMs: 5 * 60 * 1000, // 5 minutes
// How many nodes to gossip with per round
gossipFanout: 3,
// Max nodes to include in a single gossip message
maxNodesPerGossip: 100,
// Max handles to include in a single gossip message
maxHandlesPerGossip: 500,
// How long before a node is considered inactive
inactiveThresholdMs: 24 * 60 * 60 * 1000, // 24 hours
// How many consecutive failures before marking inactive
maxConsecutiveFailures: 5,
// Trust score adjustments
trustScoreOnSuccess: 1,
trustScoreOnFailure: -5,
minTrustScore: 0,
maxTrustScore: 100,
defaultTrustScore: 50,
} as const;
+1
View File
@@ -61,4 +61,5 @@ export interface Post {
handle: string; handle: string;
ownerId: string; ownerId: string;
} | null; } | null;
nodeDomain?: string | null; // Domain of the node this post came from (for swarm posts)
} }