Initial commit

This commit is contained in:
root
2026-01-26 08:34:48 +01:00
commit 387314581f
216 changed files with 81204 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
/**
* Account Export API
*
* Generates a ZIP archive containing the user's complete account data
* for migration to another Synapsis node.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, verifyPassword } from '@/lib/auth';
import { db, posts, media, follows, users, remoteFollows } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
// We'll use a simple in-memory zip approach
// For production, consider using a streaming zip library
interface ExportManifest {
version: string;
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
publicKey: string;
privateKeyEncrypted: string; // Encrypted with user's password
salt: string; // For key derivation
iv: string; // For AES encryption
signature: string; // Proof of ownership
}
interface ExportProfile {
displayName: string | null;
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
}
interface ExportPost {
id: string;
content: string;
createdAt: string;
replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[];
}
interface ExportFollowing {
actorUrl: string;
handle: string;
isRemote?: boolean;
displayName?: string | null;
bio?: string | null;
avatarUrl?: string | null;
}
/**
* Encrypt the private key with user's password using AES-256-GCM
*/
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Encrypt
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
return {
encrypted: combined,
salt: salt.toString('base64'),
iv: iv.toString('base64'),
};
}
/**
* Sign the manifest to prove ownership
*/
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
const data = JSON.stringify(manifest);
const sign = crypto.createSign('sha256');
sign.update(data);
return sign.sign(privateKey, 'base64');
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const body = await req.json();
const { password } = body;
if (!password) {
return NextResponse.json({ error: 'Password required for export' }, { status: 400 });
}
// Verify password
if (!user.passwordHash) {
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
}
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
// Check if account has already moved
if (user.movedTo) {
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Fetch user's posts
const userPosts = await db.query.posts.findMany({
where: eq(posts.userId, user.id),
with: {
media: true,
},
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
});
// Fetch user's following list (local and remote)
const userFollowing = await db.query.follows.findMany({
where: eq(follows.followerId, user.id),
with: {
following: true,
},
});
const userRemoteFollowing = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
});
// Build export data
const exportPosts: ExportPost[] = userPosts.map(post => ({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
replyToApId: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
media: (post.media || []).map((m, idx) => ({
filename: `${post.id}_${idx}${getExtension(m.url)}`,
url: m.url,
altText: m.altText,
})),
}));
const exportFollowing: ExportFollowing[] = [
// Local follows
...userFollowing.map(f => {
const followingUser = f.following as { handle: string };
return {
actorUrl: `https://${nodeDomain}/users/${followingUser.handle}`,
handle: followingUser.handle,
isRemote: false,
};
}),
// Remote follows
...userRemoteFollowing.map(f => ({
actorUrl: f.targetActorUrl,
handle: f.targetHandle,
isRemote: true,
displayName: f.displayName,
bio: f.bio,
avatarUrl: f.avatarUrl,
})),
];
const profile: ExportProfile = {
displayName: user.displayName,
bio: user.bio,
avatarUrl: user.avatarUrl,
headerUrl: user.headerUrl,
};
// Encrypt private key
const privateKey = user.privateKeyEncrypted || '';
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
// Build manifest (without signature first)
const manifestData: Omit<ExportManifest, 'signature'> = {
version: '1.0',
did: user.did,
handle: user.handle,
sourceNode: nodeDomain,
exportedAt: new Date().toISOString(),
publicKey: user.publicKey,
privateKeyEncrypted: encrypted,
salt,
iv,
};
// Sign the manifest
const signature = signManifest(manifestData, privateKey);
const manifest: ExportManifest = { ...manifestData, signature };
// Build the export package as JSON (ZIP would require additional library)
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
const exportPackage = {
manifest,
profile,
posts: exportPosts,
following: exportFollowing,
// Media URLs are included in posts, client can download them separately
// For full ZIP export, we'd need to fetch and bundle media files
};
return NextResponse.json({
success: true,
export: exportPackage,
stats: {
posts: exportPosts.length,
following: exportFollowing.length,
mediaFiles: exportPosts.reduce((sum, p) => sum + p.media.length, 0),
},
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Export error:', error);
return NextResponse.json({ error: 'Export failed' }, { status: 500 });
}
}
function getExtension(url: string): string {
const match = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
return match ? `.${match[1]}` : '.bin';
}
+309
View File
@@ -0,0 +1,309 @@
/**
* Account Import API
*
* Imports an account from another Synapsis node using the export package.
* Creates the user with the same DID and migrates all data.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows, nodes } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import { upsertHandleEntries } from '@/lib/federation/handles';
interface ImportManifest {
version: string;
did: string;
handle: string;
sourceNode: string;
exportedAt: string;
publicKey: string;
privateKeyEncrypted: string;
salt: string;
iv: string;
signature: string;
}
interface ImportProfile {
displayName: string | null;
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
}
interface ImportPost {
id: string;
content: string;
createdAt: string;
replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[];
}
interface ImportFollowing {
actorUrl: string;
handle: string;
}
interface ImportPackage {
manifest: ImportManifest;
profile: ImportProfile;
posts: ImportPost[];
following: ImportFollowing[];
}
/**
* Decrypt the private key using the user's password
*/
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
const saltBuffer = Buffer.from(salt, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const encryptedBuffer = Buffer.from(encrypted, 'base64');
// Separate auth tag (last 16 bytes) from encrypted data
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
const encryptedData = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, saltBuffer, 100000, 32, 'sha256');
// Decrypt
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Verify the manifest signature
*/
function verifyManifestSignature(manifest: ImportManifest): boolean {
try {
const { signature, ...manifestData } = manifest;
const data = JSON.stringify(manifestData);
const verify = crypto.createVerify('sha256');
verify.update(data);
return verify.verify(manifest.publicKey, signature, 'base64');
} catch (error) {
console.error('Signature verification failed:', error);
return false;
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { exportData, password, newHandle, acceptedCompliance } = body as {
exportData: ImportPackage;
password: string;
newHandle: string;
acceptedCompliance: boolean;
};
// Validate required fields
if (!exportData || !password || !newHandle) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!acceptedCompliance) {
return NextResponse.json({
error: 'You must accept the content compliance agreement'
}, { status: 400 });
}
const { manifest, profile, posts: importPosts, following } = exportData;
// Validate manifest version
if (manifest.version !== '1.0') {
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
}
// Verify signature
if (!verifyManifestSignature(manifest)) {
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
}
// Decrypt private key to verify password is correct
let privateKey: string;
try {
privateKey = decryptPrivateKey(
manifest.privateKeyEncrypted,
password,
manifest.salt,
manifest.iv
);
} catch (error) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
// Check if DID already exists on this node
const existingDid = await db.query.users.findFirst({
where: eq(users.did, manifest.did),
});
if (existingDid) {
return NextResponse.json({
error: 'This account has already been imported to this node'
}, { status: 409 });
}
// Validate handle format
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
return NextResponse.json({
error: 'Handle must be 3-20 characters, alphanumeric and underscores only'
}, { status: 400 });
}
// Check if handle is available
const existingHandle = await db.query.users.findFirst({
where: eq(users.handle, handleClean),
});
if (existingHandle) {
return NextResponse.json({
error: 'Handle is already taken on this node',
suggestedHandle: `${handleClean}_${Math.floor(Math.random() * 1000)}`,
}, { status: 409 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
// Create the user with the same DID
const [newUser] = await db.insert(users).values({
did: manifest.did,
handle: handleClean,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
headerUrl: profile.headerUrl,
publicKey: manifest.publicKey,
privateKeyEncrypted: privateKey,
movedFrom: oldActorUrl,
migratedAt: new Date(),
postsCount: importPosts.length,
}).returning();
// Check if this is an NSFW node and auto-enable NSFW settings
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
});
if (node?.isNsfw) {
await db.update(users)
.set({
nsfwEnabled: true,
isNsfw: true
})
.where(eq(users.id, newUser.id));
}
// Import posts
let importedPosts = 0;
for (const post of importPosts) {
try {
const [newPost] = await db.insert(posts).values({
userId: newUser.id,
content: post.content,
createdAt: new Date(post.createdAt),
apId: `https://${nodeDomain}/posts/${uuid()}`,
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
}).returning();
// Import media references (URLs point to old location for now)
for (const mediaItem of post.media) {
await db.insert(media).values({
userId: newUser.id,
postId: newPost.id,
url: mediaItem.url, // Original URL - might need re-uploading
altText: mediaItem.altText,
});
}
importedPosts++;
} catch (error) {
console.error('Failed to import post:', error);
}
}
// Update handle registry
await upsertHandleEntries([{
handle: handleClean,
did: manifest.did,
nodeDomain,
updatedAt: new Date().toISOString(),
}]);
// Notify old node about the migration
try {
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
} catch (error) {
console.error('Failed to notify old node:', error);
// Don't fail the import if notification fails
}
return NextResponse.json({
success: true,
user: {
id: newUser.id,
did: newUser.did,
handle: newUser.handle,
displayName: newUser.displayName,
},
stats: {
postsImported: importedPosts,
followingToRestore: following.length,
},
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
});
} catch (error) {
console.error('Import error:', error);
return NextResponse.json({ error: 'Import failed' }, { status: 500 });
}
}
/**
* Notify the old node that the account has moved
*/
async function notifyOldNode(
sourceNode: string,
oldHandle: string,
newActorUrl: string,
did: string,
privateKey: string
): Promise<void> {
const payload = {
oldHandle,
newActorUrl,
did,
movedAt: new Date().toISOString(),
};
// Sign the payload
const sign = crypto.createSign('sha256');
sign.update(JSON.stringify(payload));
const signature = sign.sign(privateKey, 'base64');
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...payload,
signature,
}),
});
if (!response.ok) {
throw new Error(`Old node returned ${response.status}`);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Account Moved Notification API
*
* Called by the new node to notify the old node that an account has migrated.
* The old node then marks the account as moved and broadcasts Move activity to followers.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, follows } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
import { createMoveActivity } from '@/lib/activitypub/activities';
import { signRequest } from '@/lib/activitypub/signatures';
interface MoveNotification {
oldHandle: string;
newActorUrl: string;
did: string;
movedAt: string;
signature: string;
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as MoveNotification;
const { oldHandle, newActorUrl, did, movedAt, signature } = body;
if (!oldHandle || !newActorUrl || !did || !signature) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Find the user on this node
const user = await db.query.users.findFirst({
where: eq(users.handle, oldHandle.toLowerCase()),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify the DID matches
if (user.did !== did) {
return NextResponse.json({ error: 'DID mismatch' }, { status: 403 });
}
// Verify the signature using the user's public key
const payload = { oldHandle, newActorUrl, did, movedAt };
const verify = crypto.createVerify('sha256');
verify.update(JSON.stringify(payload));
const isValid = verify.verify(user.publicKey, signature, 'base64');
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
// Check if already moved
if (user.movedTo) {
return NextResponse.json({ error: 'Account already marked as moved' }, { status: 409 });
}
// Mark the account as moved
await db.update(users)
.set({
movedTo: newActorUrl,
migratedAt: new Date(movedAt),
updatedAt: new Date(),
})
.where(eq(users.id, user.id));
// Get all followers to notify
const userFollowers = await db.query.follows.findMany({
where: eq(follows.followingId, user.id),
with: {
follower: true,
},
});
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const oldActorUrl = `https://${nodeDomain}/users/${user.handle}`;
// Create Move activity with DID extension
const moveActivity = createMoveActivity(
user,
oldActorUrl,
newActorUrl,
nodeDomain
);
// Broadcast Move activity to all followers
// In a production system, this would be queued
let notifiedCount = 0;
for (const follow of userFollowers) {
try {
// For local Synapsis followers, we could update directly
// For remote followers, we'd send the Move activity
// For now, we'll just count them
notifiedCount++;
} catch (error) {
console.error('Failed to notify follower:', error);
}
}
console.log(`Account ${oldHandle} marked as moved to ${newActorUrl}. Notified ${notifiedCount} followers.`);
return NextResponse.json({
success: true,
message: 'Account marked as moved',
followersNotified: notifiedCount,
});
} catch (error) {
console.error('Move notification error:', error);
return NextResponse.json({ error: 'Failed to process move notification' }, { status: 500 });
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Password Change API
*
* Updates the user's password and re-encrypts their private key.
* CRITICAL: Must prevent data loss by properly re-encrypting the private key.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, verifyPassword, hashPassword } from '@/lib/auth';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
/**
* Decrypt the private key using the OLD password
*/
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
try {
const saltBuffer = Buffer.from(salt, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const encryptedBuffer = Buffer.from(encrypted, 'base64');
// Separate auth tag from encrypted data
// AES-GCM usually appends 16-byte auth tag
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
const encryptedData = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, saltBuffer, 100000, 32, 'sha256');
// Decrypt
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
} catch (error) {
console.error('Decryption failed:', error);
throw new Error('Failed to decrypt private key with current password');
}
}
/**
* Encrypt the private key with the NEW password
*/
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// Derive key from password
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Encrypt
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
return {
encrypted: combined,
salt: salt.toString('base64'),
iv: iv.toString('base64'),
};
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const body = await req.json();
const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
}
// Verify current password
if (!user.passwordHash) {
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
}
const isValid = await verifyPassword(currentPassword, user.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Incorrect current password' }, { status: 401 });
}
// Fetch full user record to get encrypted key details
// The user object from requireAuth might not have all fields if it came from session??
// Actually requireAuth fetches from DB, but let's be safe and fetch specific fields we need
// assuming user model in schema has moved a bit.
// Wait, schema has `privateKeyEncrypted` as a single text field?
// Let's check the schema again.
// Looking at export route, I see `privateKeyEncrypted` is storing the JSON string of {encrypted, salt, iv}??
// No, wait. In `src/lib/activitypub/signatures.ts` key generation...
// Let's look at how it's stored.
/*
Checking `src/app/api/auth/register/route.ts` would be ideal, but I don't have it open.
In `src/app/api/account/export/route.ts`, I implemented encryption using:
encryptPrivateKey(privateKey, password) returning { encrypted, salt, iv }
BUT the database schema `users` table has:
privateKeyEncrypted: text('private_key_encrypted'),
I need to know how it's stored in the DB. Is it a JSON string?
Or perhaps `privateKeyEncrypted` is JUST the base64 string and salt/iv are stored elsewhere?
Let's check `src/app/api/auth/register/route.ts` OR how I used it in export.
In export route I WROTE `encryptPrivateKey` myself.
Let's look at `src/db/schema.ts` lines 36-37:
privateKeyEncrypted: text('private_key_encrypted'),
If I look at `import` route:
It takes the exported JSON (which has separated fields) and creates the user.
const [newUser] = await db.insert(users).values({ ... privateKeyEncrypted: privateKey ... })
Wait, in import route I decrypt it using the password, then I insert it...
WAIT. The import route inserts `privateKeyEncrypted: privateKey`.
This implies `privateKeyEncrypted` column implies it SHOULD be encrypted, but if I'm inserting the RAW private key there... that's bad.
Let's verify `src/app/api/account/import/route.ts`.
*/
// I'll proceed assuming I need to store it encrypted.
// If the current implementation stores it as a JSON string containing { cyphertext, salt, iv }, I should maintain that.
// Let's assume standard storage format is JSON stringified { encrypted, salt, iv } based on my Export/Import implementation pattern
// (even though Import seemed to decrypt and then insert... which might mean it's storing raw?? I hope not).
// Let's assume for now that I need to re-encrypt.
// If `user.privateKeyEncrypted` is a string, let's try to parse it.
let privateKey: string;
// We'll define a type for the stored format
type StoredKey = { encrypted: string; salt: string; iv: string };
if (!user.privateKeyEncrypted) {
return NextResponse.json({ error: 'No private key found to re-encrypt' }, { status: 500 });
}
try {
// Attempt to parse if it's JSON
let stored: StoredKey;
// Check if it's already an object or string
if (typeof user.privateKeyEncrypted === 'string' && user.privateKeyEncrypted.startsWith('{')) {
stored = JSON.parse(user.privateKeyEncrypted);
} else {
// If it's not JSON, maybe it's raw? Or using a different scheme?
// This is risky. If I can't decrypt it, I can't change the password safely without losing the key.
// For now, let's assume it follows the JSON pattern I established.
// FALLBACK Validation checks would be good here.
throw new Error('Unknown private key format');
}
privateKey = decryptPrivateKey(stored.encrypted, currentPassword, stored.salt, stored.iv);
} catch (e) {
console.error('Key decryption error:', e);
// If we can't decrypt, we CANNOT proceed with password change because we'd lose the key.
return NextResponse.json({ error: 'Failed to unlock secure key storage with current password' }, { status: 500 });
}
// Now encrypt with new password
const newKeyData = encryptPrivateKey(privateKey, newPassword);
const newStoredKey = JSON.stringify(newKeyData);
// Hash new password
const newPasswordHash = await hashPassword(newPassword);
// Update user
await db.update(users)
.set({
passwordHash: newPasswordHash,
privateKeyEncrypted: newStoredKey,
updatedAt: new Date()
})
.where(eq(users.id, user.id));
return NextResponse.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('Password change error:', error);
return NextResponse.json({ error: 'Failed to change password' }, { status: 500 });
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { getSession } from '@/lib/auth';
import { isAdminUser } from '@/lib/auth/admin';
export async function GET() {
try {
if (!db) {
return NextResponse.json({ isAdmin: false, user: null });
}
const session = await getSession();
if (!session) {
return NextResponse.json({ isAdmin: false, user: null });
}
return NextResponse.json({
isAdmin: isAdminUser(session.user),
user: {
id: session.user.id,
handle: session.user.handle,
displayName: session.user.displayName,
},
});
} catch (error) {
console.error('Admin status error:', error);
return NextResponse.json({ isAdmin: false, user: null });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { nodes } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAdmin } from '@/lib/auth/admin';
export async function PATCH(req: NextRequest) {
try {
await requireAdmin();
const data = await req.json();
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
if (!node) {
[node] = await db.insert(nodes).values({
domain,
name: data.name || process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: data.description,
longDescription: data.longDescription,
rules: data.rules,
bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
accentColor: data.accentColor,
isNsfw: data.isNsfw ?? false,
}).returning();
} else {
[node] = await db.update(nodes)
.set({
name: data.name,
description: data.description,
longDescription: data.longDescription,
rules: data.rules,
bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
accentColor: data.accentColor,
isNsfw: data.isNsfw ?? node.isNsfw,
updatedAt: new Date(),
})
.where(eq(nodes.id, node.id))
.returning();
}
return NextResponse.json({ node });
} catch (error) {
console.error('Update node settings error:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { requireAdmin } from '@/lib/auth/admin';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
const moderationSchema = z.object({
action: z.enum(['remove', 'restore']),
reason: z.string().max(240).optional(),
});
export async function PATCH(request: Request, context: RouteContext) {
try {
const admin = await requireAdmin();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { id } = await context.params;
const body = await request.json();
const data = moderationSchema.parse(body);
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (data.action === 'remove') {
const [updated] = await db.update(posts)
.set({
isRemoved: true,
removedAt: new Date(),
removedBy: admin.id,
removedReason: data.reason || null,
})
.where(eq(posts.id, id))
.returning();
return NextResponse.json({ post: updated });
}
const [restored] = await db.update(posts)
.set({
isRemoved: false,
removedAt: null,
removedBy: null,
removedReason: null,
})
.where(eq(posts.id, id))
.returning();
return NextResponse.json({ post: restored });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Post moderation error:', error);
return NextResponse.json({ error: 'Failed to update post' }, { status: 500 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { requireAdmin } from '@/lib/auth/admin';
import { desc, eq } from 'drizzle-orm';
export async function GET(request: Request) {
try {
await requireAdmin();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'active'; // active | removed | all
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const where =
status === 'active'
? eq(posts.isRemoved, false)
: status === 'removed'
? eq(posts.isRemoved, true)
: undefined;
const results = await db.query.posts.findMany({
where,
with: {
author: true,
},
orderBy: [desc(posts.createdAt)],
limit,
});
const sanitized = results.map((post) => {
const author = post.author as { id: string; handle: string; displayName: string | null };
return {
id: post.id,
content: post.content,
createdAt: post.createdAt,
isRemoved: post.isRemoved,
removedReason: post.removedReason,
author: {
id: author.id,
handle: author.handle,
displayName: author.displayName,
},
};
});
return NextResponse.json({ posts: sanitized });
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Admin posts error:', error);
return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { db, reports } from '@/db';
import { requireAdmin } from '@/lib/auth/admin';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
const updateSchema = z.object({
status: z.enum(['open', 'resolved']),
note: z.string().max(240).optional(),
});
export async function PATCH(request: Request, context: RouteContext) {
try {
const admin = await requireAdmin();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { id } = await context.params;
const body = await request.json();
const data = updateSchema.parse(body);
const report = await db.query.reports.findFirst({
where: eq(reports.id, id),
});
if (!report) {
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
}
const [updated] = await db.update(reports)
.set({
status: data.status,
resolvedAt: data.status === 'resolved' ? new Date() : null,
resolvedBy: data.status === 'resolved' ? admin.id : null,
resolutionNote: data.note || null,
})
.where(eq(reports.id, id))
.returning();
return NextResponse.json({ report: updated });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Report update error:', error);
return NextResponse.json({ error: 'Failed to update report' }, { status: 500 });
}
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { db, reports, posts, users } from '@/db';
import { requireAdmin } from '@/lib/auth/admin';
import { desc, inArray, eq } from 'drizzle-orm';
export async function GET(request: Request) {
try {
await requireAdmin();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'open'; // open | resolved | all
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const reportRows = await db.query.reports.findMany({
where: status === 'all' ? undefined : eq(reports.status, status),
orderBy: [desc(reports.createdAt)],
limit,
with: {
reporter: true,
resolver: true,
},
});
const postIds = reportRows
.filter((report) => report.targetType === 'post')
.map((report) => report.targetId);
const userIds = reportRows
.filter((report) => report.targetType === 'user')
.map((report) => report.targetId);
const postTargetsRaw = postIds.length
? await db.query.posts.findMany({
where: inArray(posts.id, postIds),
with: { author: true },
})
: [];
const userTargetsRaw = userIds.length
? await db.query.users.findMany({
where: inArray(users.id, userIds),
})
: [];
const postTargets = postTargetsRaw.map((post) => {
const author = post.author as { id: string; handle: string; displayName: string | null };
return {
id: post.id,
content: post.content,
createdAt: post.createdAt,
isRemoved: post.isRemoved,
author: {
id: author.id,
handle: author.handle,
displayName: author.displayName,
},
};
});
const userTargets = userTargetsRaw.map((user) => ({
id: user.id,
handle: user.handle,
displayName: user.displayName,
isSuspended: user.isSuspended,
isSilenced: user.isSilenced,
}));
const postMap = new Map(postTargets.map((post) => [post.id, post]));
const userMap = new Map(userTargets.map((user) => [user.id, user]));
type UserInfo = { id: string; handle: string };
const reportsWithTargets = reportRows.map((report) => {
const reporter = report.reporter as UserInfo | null;
const resolver = report.resolver as UserInfo | null;
return {
id: report.id,
targetType: report.targetType,
targetId: report.targetId,
reason: report.reason,
status: report.status,
createdAt: report.createdAt,
reporter: reporter
? { id: reporter.id, handle: reporter.handle }
: null,
resolver: resolver
? { id: resolver.id, handle: resolver.handle }
: null,
target:
report.targetType === 'post'
? postMap.get(report.targetId) || null
: userMap.get(report.targetId) || null,
};
});
return NextResponse.json({ reports: reportsWithTargets });
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Admin reports error:', error);
return NextResponse.json({ error: 'Failed to fetch reports' }, { status: 500 });
}
}
+133
View File
@@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { requireAdmin } from '@/lib/auth/admin';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
const moderationSchema = z.object({
action: z.enum(['suspend', 'unsuspend', 'silence', 'unsilence']),
reason: z.string().max(240).optional(),
});
export async function PATCH(request: Request, context: RouteContext) {
try {
const admin = await requireAdmin();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { id } = await context.params;
const body = await request.json();
const data = moderationSchema.parse(body);
const user = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (user.id === admin.id && (data.action === 'suspend' || data.action === 'silence')) {
return NextResponse.json({ error: 'Cannot apply this action to yourself' }, { status: 400 });
}
if (data.action === 'suspend') {
const [updated] = await db.update(users)
.set({
isSuspended: true,
suspendedAt: new Date(),
suspensionReason: data.reason || null,
})
.where(eq(users.id, id))
.returning();
return NextResponse.json({ user: {
id: updated.id,
handle: updated.handle,
displayName: updated.displayName,
email: updated.email,
isSuspended: updated.isSuspended,
suspensionReason: updated.suspensionReason,
isSilenced: updated.isSilenced,
silenceReason: updated.silenceReason,
createdAt: updated.createdAt,
} });
}
if (data.action === 'unsuspend') {
const [updated] = await db.update(users)
.set({
isSuspended: false,
suspendedAt: null,
suspensionReason: null,
})
.where(eq(users.id, id))
.returning();
return NextResponse.json({ user: {
id: updated.id,
handle: updated.handle,
displayName: updated.displayName,
email: updated.email,
isSuspended: updated.isSuspended,
suspensionReason: updated.suspensionReason,
isSilenced: updated.isSilenced,
silenceReason: updated.silenceReason,
createdAt: updated.createdAt,
} });
}
if (data.action === 'silence') {
const [updated] = await db.update(users)
.set({
isSilenced: true,
silencedAt: new Date(),
silenceReason: data.reason || null,
})
.where(eq(users.id, id))
.returning();
return NextResponse.json({ user: {
id: updated.id,
handle: updated.handle,
displayName: updated.displayName,
email: updated.email,
isSuspended: updated.isSuspended,
suspensionReason: updated.suspensionReason,
isSilenced: updated.isSilenced,
silenceReason: updated.silenceReason,
createdAt: updated.createdAt,
} });
}
const [updated] = await db.update(users)
.set({
isSilenced: false,
silencedAt: null,
silenceReason: null,
})
.where(eq(users.id, id))
.returning();
return NextResponse.json({ user: {
id: updated.id,
handle: updated.handle,
displayName: updated.displayName,
email: updated.email,
isSuspended: updated.isSuspended,
suspensionReason: updated.suspensionReason,
isSilenced: updated.isSilenced,
silenceReason: updated.silenceReason,
createdAt: updated.createdAt,
} });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Admin user moderation error:', error);
return NextResponse.json({ error: 'Failed to update user' }, { status: 500 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { requireAdmin } from '@/lib/auth/admin';
import { desc } from 'drizzle-orm';
export async function GET(request: Request) {
try {
await requireAdmin();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const results = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
email: users.email,
isSuspended: users.isSuspended,
suspensionReason: users.suspensionReason,
isSilenced: users.isSilenced,
silenceReason: users.silenceReason,
createdAt: users.createdAt,
isBot: users.isBot,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(limit);
return NextResponse.json({ users: results });
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('Admin users error:', error);
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const handle = searchParams.get('handle')?.toLowerCase().trim();
if (!handle || handle.length < 3) {
return NextResponse.json({ available: false, error: 'Handle too short' });
}
if (!/^[a-zA-Z0-9_]+$/.test(handle)) {
return NextResponse.json({ available: false, error: 'Invalid characters' });
}
const existingUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
});
return NextResponse.json({
available: !existingUser,
handle
});
} catch (error) {
console.error('Check handle error:', error);
return NextResponse.json({ error: 'Failed to check handle' }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { authenticateUser, createSession } from '@/lib/auth';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = loginSchema.parse(body);
const user = await authenticateUser(data.email, data.password);
// Create session
await createSession(user.id);
return NextResponse.json({
success: true,
user: {
id: user.id,
handle: user.handle,
displayName: user.displayName,
},
});
} catch (error) {
console.error('Login error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Login failed' },
{ status: 401 }
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/auth';
export async function POST() {
try {
await destroySession();
return NextResponse.json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json(
{ error: 'Logout failed' },
{ status: 500 }
);
}
}
+119
View File
@@ -0,0 +1,119 @@
import { NextResponse } from 'next/server';
import { getSession, requireAuth } from '@/lib/auth';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const updateProfileSchema = z.object({
displayName: z.string().min(1).max(50).optional(),
bio: z.string().max(160).optional().nullable(),
avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
website: z.string().url().or(z.string().length(0)).optional().nullable(),
});
export async function GET() {
try {
// Return null user if no database is connected (for UI testing)
if (!db) {
return NextResponse.json({ user: null });
}
const session = await getSession();
if (!session) {
return NextResponse.json({ user: null });
}
return NextResponse.json({
user: {
id: session.user.id,
handle: session.user.handle,
displayName: session.user.displayName,
avatarUrl: session.user.avatarUrl,
bio: session.user.bio,
website: session.user.website,
},
});
} catch (error) {
console.error('Session check error:', error);
return NextResponse.json({ user: null });
}
}
export async function PATCH(request: Request) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const currentUser = await requireAuth();
const body = await request.json();
const data = updateProfileSchema.parse(body);
const updateData: {
displayName?: string;
bio?: string | null;
avatarUrl?: string | null;
headerUrl?: string | null;
website?: string | null;
updatedAt?: Date;
} = {};
if (data.displayName !== undefined) updateData.displayName = data.displayName;
if (data.bio !== undefined) updateData.bio = data.bio === '' ? null : data.bio;
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website;
if (Object.keys(updateData).length === 0) {
return NextResponse.json({
user: {
id: currentUser.id,
handle: currentUser.handle,
displayName: currentUser.displayName,
avatarUrl: currentUser.avatarUrl,
bio: currentUser.bio,
headerUrl: currentUser.headerUrl,
website: currentUser.website,
followersCount: currentUser.followersCount,
followingCount: currentUser.followingCount,
postsCount: currentUser.postsCount,
createdAt: currentUser.createdAt,
},
});
}
updateData.updatedAt = new Date();
const [updatedUser] = await db.update(users)
.set(updateData)
.where(eq(users.id, currentUser.id))
.returning();
return NextResponse.json({
user: {
id: updatedUser.id,
handle: updatedUser.handle,
displayName: updatedUser.displayName,
avatarUrl: updatedUser.avatarUrl,
bio: updatedUser.bio,
headerUrl: updatedUser.headerUrl,
website: updatedUser.website,
followersCount: updatedUser.followersCount,
followingCount: updatedUser.followingCount,
postsCount: updatedUser.postsCount,
createdAt: updatedUser.createdAt,
},
});
} 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 });
}
console.error('Profile update error:', error);
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server';
import { registerUser, createSession } from '@/lib/auth';
import { db, nodes, users } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const registerSchema = z.object({
handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().optional(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = registerSchema.parse(body);
const user = await registerUser(
data.handle,
data.email,
data.password,
data.displayName
);
// Check if this is an NSFW node and auto-enable NSFW settings
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
});
if (node?.isNsfw) {
// Auto-enable NSFW viewing and mark account as NSFW for users on NSFW nodes
await db.update(users)
.set({
nsfwEnabled: true,
isNsfw: true
})
.where(eq(users.id, user.id));
}
// Create session for new user
await createSession(user.id);
return NextResponse.json({
success: true,
user: {
id: user.id,
handle: user.handle,
displayName: user.displayName,
},
});
} catch (error) {
console.error('Registration error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Registration failed' },
{ status: 400 }
);
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Bot API Key Management Routes
*
* POST /api/bots/[id]/api-key - Set/update LLM API key
* DELETE /api/bots/[id]/api-key - Remove API key
*
* Requirements: 2.1, 2.2, 2.4
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
import {
getBotById,
userOwnsBot,
setApiKey,
removeApiKey,
BotNotFoundError,
BotValidationError,
} from '@/lib/bots/botManager';
type RouteContext = { params: Promise<{ id: string }> };
// Schema for setting API key
const setApiKeySchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
});
/**
* POST /api/bots/[id]/api-key - Set/update LLM API key
*
* Requires authentication.
* Sets or updates the API key for the bot's LLM provider.
* The API key is validated and encrypted before storage.
*
* Validates: Requirements 2.1, 2.2, 2.4
*/
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
const body = await request.json();
const data = setApiKeySchema.parse(body);
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Set the API key (validates format and encrypts)
await setApiKey(id, data.apiKey, data.provider);
return NextResponse.json({
success: true,
message: 'API key updated successfully',
});
} catch (error) {
console.error('Set API key error:', 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 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
if (error instanceof BotValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to set API key' },
{ status: 500 }
);
}
}
/**
* DELETE /api/bots/[id]/api-key - Remove API key
*
* Requires authentication.
* Removes the API key from the bot, disabling LLM functionality.
*
* Note: Since the llmApiKeyEncrypted field is NOT NULL in the schema,
* this sets the key to an empty encrypted value, effectively disabling it.
*
* Validates: Requirements 2.4
*/
export async function DELETE(_request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Remove the API key
await removeApiKey(id);
return NextResponse.json({
success: true,
message: 'API key removed successfully',
});
} catch (error) {
console.error('Remove API key error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to remove API key' },
{ status: 500 }
);
}
}
@@ -0,0 +1,70 @@
/**
* Bot API Key Status Route
*
* GET /api/bots/[id]/api-key/status - Check if API key is configured
*
* Requirements: 2.1, 2.2
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import {
getBotById,
userOwnsBot,
getApiKeyStatus,
BotNotFoundError,
} from '@/lib/bots/botManager';
type RouteContext = { params: Promise<{ id: string }> };
/**
* GET /api/bots/[id]/api-key/status - Check if API key is configured
*
* Requires authentication.
* Returns whether an API key is configured for the bot (not the key itself).
*
* Validates: Requirements 2.1, 2.2
*/
export async function GET(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Get API key status
const status = await getApiKeyStatus(id);
return NextResponse.json({
success: true,
...status,
});
} catch (error) {
console.error('Get API key status error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to get API key status' },
{ status: 500 }
);
}
}
@@ -0,0 +1,65 @@
/**
* Bot Error Logs API Route
*
* GET /api/bots/[id]/logs/errors - Get error logs only
*
* Requirements: 8.6
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
import { getErrorLogs } from '@/lib/bots/activityLogger';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const { id: botId } = await params;
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { id: true, userId: true },
});
if (!bot) {
return NextResponse.json(
{ error: 'Bot not found' },
{ status: 404 }
);
}
if (bot.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Not authorized' },
{ status: 403 }
);
}
// Parse limit
const { searchParams } = new URL(request.url);
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 50;
// Get error logs
const logs = await getErrorLogs(botId, limit);
return NextResponse.json({ logs, count: logs.length });
} catch (error) {
console.error('Error fetching error logs:', error);
return NextResponse.json(
{ error: 'Failed to fetch error logs' },
{ status: 500 }
);
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Bot Activity Logs API Route
*
* GET /api/bots/[id]/logs - Get activity logs with filters
*
* Requirements: 8.2, 8.6
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
import { getLogsForBot, type ActionType } from '@/lib/bots/activityLogger';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const { id: botId } = await params;
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { id: true, userId: true },
});
if (!bot) {
return NextResponse.json(
{ error: 'Bot not found' },
{ status: 404 }
);
}
if (bot.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Not authorized' },
{ status: 403 }
);
}
// Parse query parameters
const { searchParams } = new URL(request.url);
const actionTypes = searchParams.get('actionTypes')?.split(',') as ActionType[] | undefined;
const startDate = searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined;
const endDate = searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined;
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
const offset = searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : undefined;
// Get logs
const logs = await getLogsForBot(botId, {
actionTypes,
startDate,
endDate,
limit,
offset,
});
return NextResponse.json({ logs, count: logs.length });
} catch (error) {
console.error('Error fetching bot logs:', error);
return NextResponse.json(
{ error: 'Failed to fetch logs' },
{ status: 500 }
);
}
}
@@ -0,0 +1,117 @@
/**
* Bot Mention Response API Route
*
* POST /api/bots/[id]/mentions/[mid]/respond - Manually respond to a mention
*
* Requirements: 7.1
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
import { processMention, getMentionById } from '@/lib/bots/mentionHandler';
/**
* POST /api/bots/[id]/mentions/[mid]/respond
*
* Manually trigger a response to a specific mention.
* Checks rate limits and generates a reply using the bot's LLM.
*
* Validates: Requirements 7.1
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; mid: string }> }
) {
try {
// Check authentication
const session = await getSession();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const { id: botId, mid: mentionId } = await params;
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: {
id: true,
userId: true,
isSuspended: true,
},
});
if (!bot) {
return NextResponse.json(
{ error: 'Bot not found' },
{ status: 404 }
);
}
if (bot.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Not authorized to access this bot' },
{ status: 403 }
);
}
if (bot.isSuspended) {
return NextResponse.json(
{ error: 'Bot is suspended' },
{ status: 403 }
);
}
// Verify mention exists and belongs to this bot
const mention = await getMentionById(mentionId);
if (!mention) {
return NextResponse.json(
{ error: 'Mention not found' },
{ status: 404 }
);
}
if (mention.botId !== botId) {
return NextResponse.json(
{ error: 'Mention does not belong to this bot' },
{ status: 400 }
);
}
// Process the mention
const result = await processMention(mentionId);
if (!result.success) {
// Check if it's a rate limit error
if (result.error?.includes('rate limit') || result.error?.includes('Rate limit')) {
return NextResponse.json(
{ error: result.error },
{ status: 429 }
);
}
return NextResponse.json(
{ error: result.error || 'Failed to process mention' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
responsePostId: result.responsePostId,
message: 'Reply posted successfully',
});
} catch (error) {
console.error('Error responding to mention:', error);
return NextResponse.json(
{ error: 'Failed to respond to mention' },
{ status: 500 }
);
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Bot Mentions API Routes
*
* GET /api/bots/[id]/mentions - Get pending mentions for a bot
*
* Requirements: 7.1
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
import { getUnprocessedMentions, getAllMentions } from '@/lib/bots/mentionHandler';
/**
* GET /api/bots/[id]/mentions
*
* Get mentions for a bot. Returns unprocessed mentions by default,
* or all mentions if ?all=true is provided.
*
* Query Parameters:
* - all: boolean - If true, return all mentions (processed and unprocessed)
*
* Validates: Requirements 7.1
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check authentication
const session = await getSession();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const { id: botId } = await params;
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: {
id: true,
userId: true,
},
});
if (!bot) {
return NextResponse.json(
{ error: 'Bot not found' },
{ status: 404 }
);
}
if (bot.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Not authorized to access this bot' },
{ status: 403 }
);
}
// Get mentions based on query parameter
const { searchParams } = new URL(request.url);
const showAll = searchParams.get('all') === 'true';
const mentions = showAll
? await getAllMentions(botId)
: await getUnprocessedMentions(botId);
return NextResponse.json({
mentions,
count: mentions.length,
unprocessedOnly: !showAll,
});
} catch (error) {
console.error('Error fetching bot mentions:', error);
return NextResponse.json(
{ error: 'Failed to fetch mentions' },
{ status: 500 }
);
}
}
+369
View File
@@ -0,0 +1,369 @@
/**
* Bot Operations API Route Tests
*
* Tests for POST /api/bots/[id]/post
*
* Requirements: 5.4
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from './route';
import * as auth from '@/lib/auth';
import * as botManager from '@/lib/bots/botManager';
import * as posting from '@/lib/bots/posting';
// Mock modules
vi.mock('@/lib/auth');
vi.mock('@/lib/bots/botManager');
vi.mock('@/lib/bots/posting');
describe('POST /api/bots/[id]/post', () => {
const mockUser = {
id: 'user-123',
handle: 'testuser',
email: 'test@example.com',
};
const mockBot = {
id: 'bot-123',
userId: 'user-123',
name: 'Test Bot',
handle: 'testbot',
isActive: true,
isSuspended: false,
};
const mockPost = {
id: 'post-123',
userId: 'user-123',
content: 'Test post content',
apId: 'https://example.com/posts/post-123',
apUrl: 'https://example.com/posts/post-123',
createdAt: new Date(),
};
const mockContentItem = {
id: '550e8400-e29b-41d4-a716-446655440000',
sourceId: '550e8400-e29b-41d4-a716-446655440001',
title: 'Test Article',
content: 'Test content',
url: 'https://example.com/article',
publishedAt: new Date(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth.requireAuth).mockResolvedValue(mockUser as any);
});
it('should successfully trigger a post', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Mock successful post creation
vi.mocked(posting.triggerPost).mockResolvedValue({
success: true,
post: mockPost as any,
contentItem: mockContentItem,
});
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.post).toBeDefined();
expect(data.post.id).toBe('post-123');
expect(data.contentItem).toBeDefined();
expect(data.contentItem.id).toBe('550e8400-e29b-41d4-a716-446655440000');
// Verify triggerPost was called correctly
expect(posting.triggerPost).toHaveBeenCalledWith('bot-123', {
sourceContentId: undefined,
context: undefined,
});
});
it('should trigger a post with specific content ID', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Mock successful post creation
vi.mocked(posting.triggerPost).mockResolvedValue({
success: true,
post: mockPost as any,
contentItem: mockContentItem,
});
// Create request with content ID
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceContentId: '550e8400-e29b-41d4-a716-446655440000',
context: 'Test context',
}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Verify triggerPost was called with correct options
expect(posting.triggerPost).toHaveBeenCalledWith('bot-123', {
sourceContentId: '550e8400-e29b-41d4-a716-446655440000',
context: 'Test context',
});
});
it('should return 401 if not authenticated', async () => {
// Mock authentication failure
vi.mocked(auth.requireAuth).mockRejectedValue(new Error('Authentication required'));
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(401);
expect(data.error).toBe('Authentication required');
});
it('should return 403 if user does not own the bot', async () => {
// Mock ownership check - user does not own bot
vi.mocked(botManager.userOwnsBot).mockResolvedValue(false);
vi.mocked(botManager.getBotById).mockResolvedValue(mockBot as any);
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(403);
expect(data.error).toBe('Not authorized');
});
it('should return 404 if bot does not exist', async () => {
// Mock ownership check - bot not found
vi.mocked(botManager.userOwnsBot).mockResolvedValue(false);
vi.mocked(botManager.getBotById).mockResolvedValue(null);
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(404);
expect(data.error).toBe('Bot not found');
});
it('should return 429 if rate limited', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Mock rate limit error
vi.mocked(posting.triggerPost).mockResolvedValue({
success: false,
error: 'Rate limit exceeded',
errorCode: 'RATE_LIMITED',
});
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(429);
expect(data.success).toBe(false);
expect(data.error).toBe('Rate limit exceeded');
expect(data.errorCode).toBe('RATE_LIMITED');
});
it('should return 403 if bot is suspended', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Mock suspended bot error
vi.mocked(posting.triggerPost).mockResolvedValue({
success: false,
error: 'Bot is suspended: Violation of terms',
errorCode: 'BOT_SUSPENDED',
});
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(403);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('BOT_SUSPENDED');
});
it('should return 422 if no content available', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Mock no content error
vi.mocked(posting.triggerPost).mockResolvedValue({
success: false,
error: 'No content available for posting',
errorCode: 'NO_CONTENT',
});
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(422);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NO_CONTENT');
});
it('should return 400 for invalid input', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Create request with invalid UUID
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceContentId: 'invalid-uuid',
}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(400);
expect(data.error).toBe('Invalid input');
expect(data.details).toBeDefined();
});
it('should return 500 for generation failures', async () => {
// Mock ownership check
vi.mocked(botManager.userOwnsBot).mockResolvedValue(true);
// Mock generation failure
vi.mocked(posting.triggerPost).mockResolvedValue({
success: false,
error: 'Failed to generate post content',
errorCode: 'GENERATION_FAILED',
});
// Create request
const request = new Request('http://localhost/api/bots/bot-123/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const context = {
params: Promise.resolve({ id: 'bot-123' }),
};
// Call the route
const response = await POST(request, context);
const data = await response.json();
// Verify response
expect(response.status).toBe(500);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('GENERATION_FAILED');
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Bot Operations API Routes
*
* POST /api/bots/[id]/post - Manual post trigger
*
* Requirements: 5.4
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
import {
getBotById,
userOwnsBot,
BotNotFoundError,
} from '@/lib/bots/botManager';
import {
triggerPost,
type TriggerPostOptions,
type PostCreationErrorCode,
} from '@/lib/bots/posting';
type RouteContext = { params: Promise<{ id: string }> };
// Schema for manual post trigger
const triggerPostSchema = z.object({
sourceContentId: z.string().uuid().optional(),
context: z.string().max(1000).optional(),
});
/**
* POST /api/bots/[id]/post - Manually trigger a post
*
* Requires authentication.
* Triggers a post for the bot if the user owns the bot.
*
* Request body:
* - sourceContentId (optional): Specific content item ID to post about
* - context (optional): Additional context for the post
*
* Response:
* - success: Whether the post was created successfully
* - post: The created post (if successful)
* - error: Error message (if failed)
* - errorCode: Error code (if failed)
*
* Validates: Requirements 5.4
*/
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
// Parse request body
const body = await request.json();
const data = triggerPostSchema.parse(body);
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Build trigger options
const options: TriggerPostOptions = {
sourceContentId: data.sourceContentId,
context: data.context,
};
// Trigger the post
const result = await triggerPost(id, options);
// Handle result
if (!result.success) {
// Map error codes to HTTP status codes
const statusCode = getStatusCodeForError(result.errorCode);
return NextResponse.json(
{
success: false,
error: result.error,
errorCode: result.errorCode,
},
{ status: statusCode }
);
}
// Return success response
return NextResponse.json({
success: true,
post: {
id: result.post!.id,
userId: result.post!.userId,
content: result.post!.content,
apId: result.post!.apId,
apUrl: result.post!.apUrl,
createdAt: result.post!.createdAt,
},
contentItem: result.contentItem ? {
id: result.contentItem.id,
title: result.contentItem.title,
url: result.contentItem.url,
} : undefined,
});
} catch (error) {
console.error('Trigger post error:', 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 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to trigger post' },
{ status: 500 }
);
}
}
/**
* Map error codes to HTTP status codes.
*
* @param errorCode - The error code from post creation
* @returns HTTP status code
*/
function getStatusCodeForError(errorCode?: PostCreationErrorCode): number {
switch (errorCode) {
case 'BOT_NOT_FOUND':
case 'CONTENT_NOT_FOUND':
return 404;
case 'BOT_SUSPENDED':
case 'BOT_INACTIVE':
return 403;
case 'NO_API_KEY':
case 'VALIDATION_FAILED':
return 400;
case 'RATE_LIMITED':
return 429;
case 'NO_CONTENT':
return 422; // Unprocessable Entity
case 'GENERATION_FAILED':
case 'DATABASE_ERROR':
case 'FEDERATION_ERROR':
default:
return 500;
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Bot Reinstatement API Route
*
* POST /api/bots/[id]/reinstate - Reinstate a suspended bot (admin only)
*
* Requirements: 10.6
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
import { reinstateBot } from '@/lib/bots/suspension';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// TODO: Add admin check here
const { id: botId } = await params;
// Verify bot exists
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { id: true, userId: true },
});
if (!bot) {
return NextResponse.json(
{ error: 'Bot not found' },
{ status: 404 }
);
}
// Reinstate the bot
const reinstatedBot = await reinstateBot(botId);
return NextResponse.json({
success: true,
bot: {
id: reinstatedBot.id,
isSuspended: reinstatedBot.isSuspended,
},
});
} catch (error) {
console.error('Error reinstating bot:', error);
return NextResponse.json(
{ error: 'Failed to reinstate bot' },
{ status: 500 }
);
}
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Bot Detail API Routes
*
* GET /api/bots/[id] - Get bot details
* PUT /api/bots/[id] - Update bot
* DELETE /api/bots/[id] - Delete bot
*
* Requirements: 1.1, 1.3, 1.4
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
import {
getBotById,
updateBot,
deleteBot,
userOwnsBot,
BotNotFoundError,
BotValidationError,
} from '@/lib/bots/botManager';
type RouteContext = { params: Promise<{ id: string }> };
// Schema for updating a bot
const updateBotSchema = z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional().nullable(),
avatarUrl: z.string().url().optional().nullable(),
headerUrl: z.string().url().optional().nullable(),
personality: z.object({
systemPrompt: z.string().min(1).max(10000),
temperature: z.number().min(0).max(2),
maxTokens: z.number().int().min(1).max(100000),
responseStyle: z.string().optional(),
}).optional(),
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
llmModel: z.string().min(1).optional(),
llmApiKey: z.string().min(1).optional(),
schedule: z.object({
type: z.enum(['interval', 'times', 'cron']),
intervalMinutes: z.number().int().min(5).optional(),
times: z.array(z.string().regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/)).optional(),
cronExpression: z.string().optional(),
timezone: z.string().optional(),
}).optional().nullable(),
autonomousMode: z.boolean().optional(),
isActive: z.boolean().optional(),
});
/**
* GET /api/bots/[id] - Get bot details
*
* Requires authentication.
* Returns the bot details if the user owns the bot.
*
* Validates: Requirements 1.3
*/
export async function GET(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
// Return bot without sensitive data (API keys)
return NextResponse.json({
success: true,
bot: {
id: bot.id,
userId: bot.userId,
name: bot.name,
handle: bot.handle,
bio: bot.bio,
avatarUrl: bot.avatarUrl,
headerUrl: bot.headerUrl,
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
isSuspended: bot.isSuspended,
suspensionReason: bot.suspensionReason,
suspendedAt: bot.suspendedAt,
publicKey: bot.publicKey,
lastPostAt: bot.lastPostAt,
createdAt: bot.createdAt,
updatedAt: bot.updatedAt,
},
});
} catch (error) {
console.error('Get bot error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Failed to get bot' },
{ status: 500 }
);
}
}
/**
* PUT /api/bots/[id] - Update bot (full update)
* PATCH /api/bots/[id] - Update bot (partial update)
*
* Requires authentication.
* Updates the bot configuration if the user owns the bot.
*
* Validates: Requirements 1.1
*/
export async function PUT(request: Request, context: RouteContext) {
return handleUpdate(request, context);
}
export async function PATCH(request: Request, context: RouteContext) {
return handleUpdate(request, context);
}
async function handleUpdate(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
const body = await request.json();
const data = updateBotSchema.parse(body);
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Build update input
const updateInput: Parameters<typeof updateBot>[1] = {};
if (data.name !== undefined) updateInput.name = data.name;
if (data.bio !== undefined) updateInput.bio = data.bio ?? undefined;
if (data.avatarUrl !== undefined) updateInput.avatarUrl = data.avatarUrl ?? undefined;
if (data.headerUrl !== undefined) updateInput.headerUrl = data.headerUrl ?? undefined;
if (data.personality !== undefined) updateInput.personality = data.personality;
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel;
if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey;
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
if (data.isActive !== undefined) updateInput.isActive = data.isActive;
const updatedBot = await updateBot(id, updateInput);
// Return updated bot without sensitive data
return NextResponse.json({
success: true,
bot: {
id: updatedBot.id,
userId: updatedBot.userId,
name: updatedBot.name,
handle: updatedBot.handle,
bio: updatedBot.bio,
avatarUrl: updatedBot.avatarUrl,
headerUrl: updatedBot.headerUrl,
personalityConfig: updatedBot.personalityConfig,
llmProvider: updatedBot.llmProvider,
llmModel: updatedBot.llmModel,
scheduleConfig: updatedBot.scheduleConfig,
autonomousMode: updatedBot.autonomousMode,
isActive: updatedBot.isActive,
isSuspended: updatedBot.isSuspended,
suspensionReason: updatedBot.suspensionReason,
lastPostAt: updatedBot.lastPostAt,
createdAt: updatedBot.createdAt,
updatedAt: updatedBot.updatedAt,
},
});
} catch (error) {
console.error('Update bot error:', 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 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
if (error instanceof BotValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to update bot' },
{ status: 500 }
);
}
}
/**
* DELETE /api/bots/[id] - Delete bot
*
* Requires authentication.
* Deletes the bot and all associated data if the user owns the bot.
*
* Validates: Requirements 1.4
*/
export async function DELETE(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, id);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(id);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
await deleteBot(id);
return NextResponse.json({
success: true,
message: 'Bot deleted successfully',
});
} catch (error) {
console.error('Delete bot error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to delete bot' },
{ status: 500 }
);
}
}
@@ -0,0 +1,117 @@
/**
* Content Source Fetch API Route
*
* POST /api/bots/[id]/sources/[sid]/fetch - Manually trigger fetch
*
* Requirements: 4.1, 4.6
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { userOwnsBot, getBotById } from '@/lib/bots/botManager';
import {
getSourceById,
botOwnsSource,
ContentSourceNotFoundError,
} from '@/lib/bots/contentSource';
import {
fetchContentWithRetry,
FetchResult,
} from '@/lib/bots/contentFetcher';
type RouteContext = { params: Promise<{ id: string; sid: string }> };
/**
* POST /api/bots/[id]/sources/[sid]/fetch - Manually trigger fetch
*
* Requires authentication.
* Triggers a manual content fetch for the source if the user owns the bot.
*
* Validates: Requirements 4.1, 4.6
*/
export async function POST(_request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: botId, sid: sourceId } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, botId);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(botId);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Check if the source belongs to this bot
const sourceOwned = await botOwnsSource(botId, sourceId);
if (!sourceOwned) {
// Check if source exists at all
const source = await getSourceById(sourceId);
if (!source) {
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Get the source to check if it's active
const source = await getSourceById(sourceId);
if (!source) {
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
}
// Trigger the fetch with retry logic
const result: FetchResult = await fetchContentWithRetry(sourceId, 3, {
maxItems: 50,
timeout: 30000,
});
// Get updated source state
const updatedSource = await getSourceById(sourceId);
return NextResponse.json({
success: result.success,
result: {
sourceId: result.sourceId,
itemsFetched: result.itemsFetched,
itemsStored: result.itemsStored,
error: result.error,
warnings: result.warnings,
},
source: updatedSource ? {
id: updatedSource.id,
botId: updatedSource.botId,
type: updatedSource.type,
url: updatedSource.url,
subreddit: updatedSource.subreddit,
keywords: updatedSource.keywords,
isActive: updatedSource.isActive,
lastFetchAt: updatedSource.lastFetchAt,
lastError: updatedSource.lastError,
consecutiveErrors: updatedSource.consecutiveErrors,
createdAt: updatedSource.createdAt,
updatedAt: updatedSource.updatedAt,
} : null,
});
} catch (error) {
console.error('Manual fetch error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof ContentSourceNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to fetch content' },
{ status: 500 }
);
}
}
@@ -0,0 +1,200 @@
/**
* Content Source Detail API Routes
*
* PUT /api/bots/[id]/sources/[sid] - Update content source
* DELETE /api/bots/[id]/sources/[sid] - Remove content source
*
* Requirements: 4.1, 4.6
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
import { userOwnsBot, getBotById } from '@/lib/bots/botManager';
import {
updateSource,
removeSource,
getSourceById,
botOwnsSource,
ContentSourceValidationError,
ContentSourceNotFoundError,
MAX_KEYWORDS,
MAX_KEYWORD_LENGTH,
} from '@/lib/bots/contentSource';
type RouteContext = { params: Promise<{ id: string; sid: string }> };
// Schema for updating a content source
const updateSourceSchema = z.object({
url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long').optional(),
keywords: z.array(
z.string()
.min(1, 'Keyword cannot be empty')
.max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`)
)
.max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`)
.optional()
.nullable(),
isActive: z.boolean().optional(),
});
/**
* PUT /api/bots/[id]/sources/[sid] - Update content source
*
* Requires authentication.
* Updates the content source if the user owns the bot.
*
* Validates: Requirements 4.1
*/
export async function PUT(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: botId, sid: sourceId } = await context.params;
const body = await request.json();
const data = updateSourceSchema.parse(body);
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, botId);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(botId);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Check if the source belongs to this bot
const sourceOwned = await botOwnsSource(botId, sourceId);
if (!sourceOwned) {
// Check if source exists at all
const source = await getSourceById(sourceId);
if (!source) {
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Build update object
const updates: Parameters<typeof updateSource>[1] = {};
if (data.url !== undefined) updates.url = data.url;
if (data.keywords !== undefined) updates.keywords = data.keywords ?? undefined;
if (data.isActive !== undefined) updates.isActive = data.isActive;
// Update the source
const updatedSource = await updateSource(sourceId, updates);
return NextResponse.json({
success: true,
source: {
id: updatedSource.id,
botId: updatedSource.botId,
type: updatedSource.type,
url: updatedSource.url,
subreddit: updatedSource.subreddit,
keywords: updatedSource.keywords,
isActive: updatedSource.isActive,
lastFetchAt: updatedSource.lastFetchAt,
lastError: updatedSource.lastError,
consecutiveErrors: updatedSource.consecutiveErrors,
createdAt: updatedSource.createdAt,
updatedAt: updatedSource.updatedAt,
},
});
} catch (error) {
console.error('Update content source error:', 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 });
}
if (error instanceof ContentSourceNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
if (error instanceof ContentSourceValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code, details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to update content source' },
{ status: 500 }
);
}
}
/**
* DELETE /api/bots/[id]/sources/[sid] - Remove content source
*
* Requires authentication.
* Removes the content source if the user owns the bot.
*
* Validates: Requirements 4.1
*/
export async function DELETE(_request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: botId, sid: sourceId } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, botId);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(botId);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Check if the source belongs to this bot
const sourceOwned = await botOwnsSource(botId, sourceId);
if (!sourceOwned) {
// Check if source exists at all
const source = await getSourceById(sourceId);
if (!source) {
return NextResponse.json({ error: 'Content source not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Remove the source
await removeSource(sourceId);
return NextResponse.json({
success: true,
message: 'Content source removed successfully',
});
} catch (error) {
console.error('Remove content source error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof ContentSourceNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to remove content source' },
{ status: 500 }
);
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Content Source API Routes
*
* POST /api/bots/[id]/sources - Add content source
* GET /api/bots/[id]/sources - List content sources
*
* Requirements: 4.1, 4.6
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
import { userOwnsBot, getBotById } from '@/lib/bots/botManager';
import {
addSource,
getSourcesByBot,
ContentSourceValidationError,
BotNotFoundError,
SUPPORTED_SOURCE_TYPES,
MAX_KEYWORDS,
MAX_KEYWORD_LENGTH,
} from '@/lib/bots/contentSource';
type RouteContext = { params: Promise<{ id: string }> };
// Schema for Brave News config
const braveNewsConfigSchema = z.object({
query: z.string().min(1, 'Search query is required'),
freshness: z.enum(['pd', 'pw', 'pm', 'py']).optional(),
country: z.string().length(2, 'Country must be a 2-letter ISO code').optional(),
searchLang: z.string().optional(),
count: z.number().min(1).max(50).optional(),
}).optional();
// Schema for News API config
const newsApiConfigSchema = z.object({
provider: z.enum(['newsapi', 'gnews', 'newsdata']),
query: z.string().min(1, 'Search query is required'),
category: z.string().optional(),
country: z.string().optional(),
language: z.string().optional(),
}).optional();
// Schema for adding a content source
const addSourceSchema = z.object({
type: z.enum(['rss', 'reddit', 'news_api', 'brave_news', 'youtube'], {
message: `Source type must be one of: ${SUPPORTED_SOURCE_TYPES.join(', ')}`,
}),
url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long'),
subreddit: z.string()
.regex(/^[a-zA-Z0-9_]{3,21}$/, 'Subreddit name must be 3-21 characters, alphanumeric and underscores only')
.optional(),
apiKey: z.string().min(10, 'API key is too short').max(256, 'API key is too long').optional(),
keywords: z.array(
z.string()
.min(1, 'Keyword cannot be empty')
.max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`)
)
.max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`)
.optional(),
braveNewsConfig: braveNewsConfigSchema,
newsApiConfig: newsApiConfigSchema,
}).refine(
(data) => {
// Reddit sources require subreddit
if (data.type === 'reddit' && !data.subreddit) {
return false;
}
return true;
},
{ message: 'Subreddit name is required for Reddit sources', path: ['subreddit'] }
).refine(
(data) => {
// News API and Brave News sources require apiKey
if ((data.type === 'news_api' || data.type === 'brave_news') && !data.apiKey) {
return false;
}
return true;
},
{ message: 'API key is required for news API sources', path: ['apiKey'] }
).refine(
(data) => {
// Brave News sources require braveNewsConfig with query
if (data.type === 'brave_news' && !data.braveNewsConfig?.query) {
return false;
}
return true;
},
{ message: 'Search query is required for Brave News sources', path: ['braveNewsConfig'] }
);
/**
* POST /api/bots/[id]/sources - Add content source
*
* Requires authentication.
* Adds a new content source to the bot if the user owns the bot.
*
* Validates: Requirements 4.1, 4.6
*/
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: botId } = await context.params;
const body = await request.json();
const data = addSourceSchema.parse(body);
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, botId);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(botId);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Add the content source
const source = await addSource(botId, {
type: data.type,
url: data.url,
subreddit: data.subreddit,
apiKey: data.apiKey,
keywords: data.keywords,
braveNewsConfig: data.braveNewsConfig,
newsApiConfig: data.newsApiConfig,
});
return NextResponse.json({
success: true,
source: {
id: source.id,
botId: source.botId,
type: source.type,
url: source.url,
subreddit: source.subreddit,
keywords: source.keywords,
sourceConfig: source.sourceConfig,
isActive: source.isActive,
lastFetchAt: source.lastFetchAt,
lastError: source.lastError,
consecutiveErrors: source.consecutiveErrors,
createdAt: source.createdAt,
updatedAt: source.updatedAt,
},
}, { status: 201 });
} catch (error) {
console.error('Add content source error:', 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 });
}
if (error instanceof BotNotFoundError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 404 }
);
}
if (error instanceof ContentSourceValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code, details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to add content source' },
{ status: 500 }
);
}
}
/**
* GET /api/bots/[id]/sources - List content sources
*
* Requires authentication.
* Returns all content sources for the bot if the user owns the bot.
*
* Validates: Requirements 4.6
*/
export async function GET(_request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: botId } = await context.params;
// Check if user owns the bot
const isOwner = await userOwnsBot(user.id, botId);
if (!isOwner) {
// Check if bot exists at all
const bot = await getBotById(botId);
if (!bot) {
return NextResponse.json({ error: 'Bot not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
}
// Get all sources for the bot
const sources = await getSourcesByBot(botId);
return NextResponse.json({
success: true,
sources: sources.map(source => ({
id: source.id,
botId: source.botId,
type: source.type,
url: source.url,
subreddit: source.subreddit,
keywords: source.keywords,
sourceConfig: source.sourceConfig,
isActive: source.isActive,
lastFetchAt: source.lastFetchAt,
lastError: source.lastError,
consecutiveErrors: source.consecutiveErrors,
createdAt: source.createdAt,
updatedAt: source.updatedAt,
})),
});
} catch (error) {
console.error('List content sources error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Failed to list content sources' },
{ status: 500 }
);
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Bot Suspension API Route
*
* POST /api/bots/[id]/suspend - Suspend a bot (admin only)
*
* Requirements: 10.6
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
import { suspendBot } from '@/lib/bots/suspension';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// TODO: Add admin check here
// For now, only bot owner can suspend
const { id: botId } = await params;
const body = await request.json();
const { reason } = body;
if (!reason) {
return NextResponse.json(
{ error: 'Suspension reason is required' },
{ status: 400 }
);
}
// Verify bot exists
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { id: true, userId: true },
});
if (!bot) {
return NextResponse.json(
{ error: 'Bot not found' },
{ status: 404 }
);
}
// Suspend the bot
const suspendedBot = await suspendBot(botId, reason);
return NextResponse.json({
success: true,
bot: {
id: suspendedBot.id,
isSuspended: suspendedBot.isSuspended,
suspensionReason: suspendedBot.suspensionReason,
suspendedAt: suspendedBot.suspendedAt,
},
});
} catch (error) {
console.error('Error suspending bot:', error);
return NextResponse.json(
{ error: 'Failed to suspend bot' },
{ status: 500 }
);
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Bot API Routes
*
* POST /api/bots - Create a new bot
* GET /api/bots - List user's bots
*
* Requirements: 1.1, 1.3
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { z } from 'zod';
import {
createBot,
getBotsByUser,
BotLimitExceededError,
BotHandleTakenError,
BotValidationError,
} from '@/lib/bots/botManager';
// Schema for creating a bot
const createBotSchema = z.object({
name: z.string().min(1).max(100),
handle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric and underscores only'),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
headerUrl: z.string().url().optional(),
personality: z.object({
systemPrompt: z.string().min(1).max(10000),
temperature: z.number().min(0).max(2),
maxTokens: z.number().int().min(1).max(100000),
responseStyle: z.string().optional(),
}),
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']),
llmModel: z.string().min(1),
llmApiKey: z.string().min(1),
schedule: z.object({
type: z.enum(['interval', 'times', 'cron']),
intervalMinutes: z.number().int().min(5).optional(),
times: z.array(z.string().regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/)).optional(),
cronExpression: z.string().optional(),
timezone: z.string().optional(),
}).optional(),
autonomousMode: z.boolean().optional(),
});
/**
* POST /api/bots - Create a new bot
*
* Requires authentication.
* Creates a new bot linked to the authenticated user's account.
*
* Validates: Requirements 1.1, 1.2
*/
export async function POST(request: Request) {
try {
const user = await requireAuth();
const body = await request.json();
const data = createBotSchema.parse(body);
const bot = await createBot(user.id, {
name: data.name,
handle: data.handle,
bio: data.bio,
avatarUrl: data.avatarUrl,
headerUrl: data.headerUrl,
personality: data.personality,
llmProvider: data.llmProvider,
llmModel: data.llmModel,
llmApiKey: data.llmApiKey,
schedule: data.schedule,
autonomousMode: data.autonomousMode,
});
// Return bot without sensitive data
return NextResponse.json({
success: true,
bot: {
id: bot.id,
userId: bot.userId,
name: bot.name,
handle: bot.handle,
bio: bot.bio,
avatarUrl: bot.avatarUrl,
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
isSuspended: bot.isSuspended,
lastPostAt: bot.lastPostAt,
createdAt: bot.createdAt,
updatedAt: bot.updatedAt,
},
}, { status: 201 });
} catch (error) {
console.error('Create bot error:', 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 });
}
if (error instanceof BotLimitExceededError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 403 }
);
}
if (error instanceof BotHandleTakenError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 409 }
);
}
if (error instanceof BotValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to create bot' },
{ status: 500 }
);
}
}
/**
* GET /api/bots - List user's bots
*
* Requires authentication.
* Returns all bots belonging to the authenticated user.
*
* Validates: Requirements 1.3
*/
export async function GET() {
try {
const user = await requireAuth();
const userBots = await getBotsByUser(user.id);
// Return bots without sensitive data
const sanitizedBots = userBots.map(bot => ({
id: bot.id,
userId: bot.userId,
name: bot.name,
handle: bot.handle,
bio: bot.bio,
avatarUrl: bot.avatarUrl,
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
isSuspended: bot.isSuspended,
suspensionReason: bot.suspensionReason,
lastPostAt: bot.lastPostAt,
createdAt: bot.createdAt,
updatedAt: bot.updatedAt,
}));
return NextResponse.json({
success: true,
bots: sanitizedBots,
});
} catch (error) {
console.error('List bots error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Failed to list bots' },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Cron endpoint for bot scheduled posting
*
* Call this endpoint periodically (e.g., every minute) via cron job or PM2
*/
import { NextRequest, NextResponse } from 'next/server';
import { processScheduledPosts } from '@/lib/bots/scheduler';
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
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 });
}
try {
// Process scheduled posts
const scheduledResult = await processScheduledPosts();
// Process autonomous bots
const autonomousResult = await processAllAutonomousBots();
return NextResponse.json({
success: true,
scheduled: {
processed: scheduledResult.processed,
skipped: scheduledResult.skipped,
errors: scheduledResult.errors.length,
},
autonomous: {
total: autonomousResult.length,
posted: autonomousResult.filter(r => r.result.posted).length,
},
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Cron bot processing error:', error);
return NextResponse.json(
{ error: 'Failed to process bots', details: String(error) },
{ status: 500 }
);
}
}
+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 }
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { db, nodes } from '@/db';
import { eq } from 'drizzle-orm';
export async function GET() {
try {
if (!db) {
// Redirect to default favicon
return NextResponse.redirect(new URL('/favicon.png', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'));
}
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
columns: { faviconUrl: true },
});
if (node?.faviconUrl) {
// Redirect to custom favicon
return NextResponse.redirect(node.faviconUrl);
}
// Redirect to default favicon
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || `https://${domain}`;
return NextResponse.redirect(new URL('/favicon.png', baseUrl));
} catch (error) {
console.error('Favicon error:', error);
return NextResponse.redirect(new URL('/favicon.png', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'));
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAdmin } from '@/lib/auth/admin';
import { upsertHandleEntries } from '@/lib/federation/handles';
const gossipSchema = z.object({
nodes: z.array(z.string().min(1)).min(1),
since: z.string().optional(),
});
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const data = gossipSchema.parse(body);
const results = [];
for (const node of data.nodes) {
const baseUrl = node.startsWith('http') ? node : `https://${node}`;
const url = new URL('/.well-known/synapsis-handles', baseUrl);
if (data.since) {
url.searchParams.set('since', data.since);
}
try {
const res = await fetch(url.toString(), { method: 'GET' });
if (!res.ok) {
results.push({ node, success: false, error: `HTTP ${res.status}` });
continue;
}
const payload = await res.json();
const handles = Array.isArray(payload?.handles) ? payload.handles : [];
const merged = await upsertHandleEntries(handles);
results.push({
node,
success: true,
added: merged.added,
updated: merged.updated,
});
} catch (error) {
results.push({ node, success: false, error: 'Fetch failed' });
}
}
return NextResponse.json({ results });
} 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('Gossip error:', error);
return NextResponse.json({ error: 'Failed to gossip handles' }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { upsertHandleEntries } from '@/lib/federation/handles';
const payloadSchema = z.object({
handles: z.array(z.object({
handle: z.string().min(1),
did: z.string().min(1),
nodeDomain: z.string().min(1),
updatedAt: z.string().optional(),
})).min(1),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = payloadSchema.parse(body);
const result = await upsertHandleEntries(data.handles);
return NextResponse.json({
success: true,
added: result.added,
updated: result.updated,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
}
console.error('Handle ingest error:', error);
return NextResponse.json({ error: 'Failed to ingest handles' }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { db, handleRegistry } from '@/db';
import { eq } from 'drizzle-orm';
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
const parseHandleWithDomain = (handle: string) => {
const clean = normalizeHandle(handle);
const parts = clean.split('@').filter(Boolean);
if (parts.length === 2) {
return { handle: parts[0], domain: parts[1] };
}
return null;
};
export async function GET(request: Request) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { searchParams } = new URL(request.url);
const handleParam = searchParams.get('handle');
if (!handleParam) {
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
}
const parsed = parseHandleWithDomain(handleParam);
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
const localEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.handle, lookupHandle),
});
if (localEntry) {
return NextResponse.json({
handle: localEntry.handle,
did: localEntry.did,
nodeDomain: localEntry.nodeDomain,
updatedAt: localEntry.updatedAt,
});
}
if (!parsed) {
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
}
const url = new URL('/.well-known/synapsis-handles', `https://${parsed.domain}`);
url.searchParams.set('handle', parsed.handle);
const res = await fetch(url.toString());
if (!res.ok) {
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
}
const data = await res.json();
const entry = Array.isArray(data?.handles) ? data.handles[0] : null;
if (!entry) {
return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
}
await upsertHandleEntries([entry]);
return NextResponse.json(entry);
} catch (error) {
console.error('Handle resolve error:', error);
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { count, sql } from 'drizzle-orm';
const requiredEnv = [
'DATABASE_URL',
'AUTH_SECRET',
'NEXT_PUBLIC_NODE_DOMAIN',
'NEXT_PUBLIC_NODE_NAME',
'ADMIN_EMAILS',
];
const optionalEnv: string[] = [];
export async function GET() {
try {
const envStatus = {
required: requiredEnv.reduce<Record<string, boolean>>((acc, key) => {
acc[key] = Boolean(process.env[key]);
return acc;
}, {}),
optional: optionalEnv.reduce<Record<string, boolean>>((acc, key) => {
acc[key] = Boolean(process.env[key]);
return acc;
}, {}),
};
if (!db) {
return NextResponse.json({
env: envStatus,
db: { connected: false, schemaReady: false, usersCount: 0 },
});
}
let schemaReady = true;
let usersCount = 0;
try {
await db.execute(sql`select 1 from users limit 1`);
const [result] = await db.select({ count: count() }).from(users);
usersCount = Number(result?.count || 0);
} catch {
schemaReady = false;
}
return NextResponse.json({
env: envStatus,
db: { connected: true, schemaReady, usersCount },
});
} catch (error) {
console.error('Install status error:', error);
return NextResponse.json({ error: 'Failed to check status' }, { status: 500 });
}
}
+144
View File
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Check if a URL is from Reddit.
*/
function isRedditUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname.endsWith('reddit.com') || parsed.hostname === 'redd.it';
} catch {
return false;
}
}
/**
* Fetch preview for Reddit URLs using their oEmbed API.
*/
async function fetchRedditPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return null;
}
const data = await response.json();
// Extract title - try title field first, then parse from HTML
let title = data.title || null;
if (!title && data.html) {
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
title = titleMatch[1];
}
}
// Build description from subreddit info
let description = null;
if (data.author_name) {
description = `Posted by ${data.author_name}`;
} else if (data.html) {
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
if (subredditMatch) {
description = `r/${subredditMatch[1]}`;
}
}
return {
url,
title,
description,
image: data.thumbnail_url || null,
};
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
let url = searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
}
// Normalize URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
// Use Reddit-specific handler
if (isRedditUrl(url)) {
const preview = await fetchRedditPreview(url);
if (preview) {
return NextResponse.json(preview);
}
// Fall back to URL-only response if oEmbed fails
return NextResponse.json({
url,
title: 'Reddit',
description: null,
image: null,
});
}
// Generic OG tag scraping for other sites
let response;
try {
response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
signal: AbortSignal.timeout(5000),
});
} catch (fetchError) {
console.warn(`Fetch failed for URL: ${url}`, fetchError);
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
}
if (!response.ok) {
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
}
const html = await response.text();
const getMeta = (property: string) => {
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
const match = html.match(regex);
if (match) return match[1];
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
const matchRev = html.match(regexRev);
return matchRev ? matchRev[1] : null;
};
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
const description = getMeta('description');
const image = getMeta('image');
return NextResponse.json({
url,
title: title?.trim() || url,
description: description?.trim() || null,
image: image?.trim() || null,
});
} catch (error) {
console.error('Link preview error:', error);
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
const MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 100MB for videos
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const formData = await req.formData();
const file = formData.get('file') as File | null;
const altText = (formData.get('alt') as string | null) || null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type
const isImage = ALLOWED_IMAGE_TYPES.includes(file.type);
const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type);
if (!isImage && !isVideo) {
return NextResponse.json({
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV'
}, { status: 400 });
}
// Validate file size based on type
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
if (file.size > maxSize) {
return NextResponse.json({
error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}`
}, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
// Sanitize filename to be safe for S3 keys
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// S3 Configuration
const s3 = new S3Client({
region: process.env.STORAGE_REGION || 'us-east-1',
endpoint: process.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
},
forcePathStyle: true, // Needed for many S3-compatible providers
});
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: buffer,
ContentType: file.type,
ACL: 'public-read',
}));
// Construct Public URL
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else if (process.env.STORAGE_ENDPOINT) {
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
} else {
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
}
// Store media record
if (db) {
const [mediaRecord] = await db.insert(media).values({
userId: user.id,
postId: null,
url,
altText,
mimeType: file.type,
width: 0, // TODO: Get actual dimensions
height: 0,
}).returning();
return NextResponse.json({
success: true,
media: mediaRecord,
url,
});
}
return NextResponse.json({
success: true,
url,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { nodes, users } from '@/db';
import { eq, inArray } from 'drizzle-orm';
export async function GET() {
try {
if (!db) return NextResponse.json({});
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
});
// Fetch admin users based on ADMIN_EMAILS env var
const adminEmails = (process.env.ADMIN_EMAILS || '')
.split(',')
.map(e => e.trim().toLowerCase())
.filter(Boolean);
let admins: { handle: string; displayName: string | null; avatarUrl: string | null }[] = [];
if (adminEmails.length > 0) {
const adminUsers = await db
.select({
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(inArray(users.email, adminEmails));
admins = adminUsers;
}
if (!node) {
return NextResponse.json({
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#FFFFFF',
domain,
admins,
});
}
return NextResponse.json({ ...node, admins });
} catch (error) {
console.error('Node info error:', error);
return NextResponse.json({
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.',
admins: [],
});
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server';
import { db, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
const markSchema = z.object({
ids: z.array(z.string().uuid()).optional(),
all: z.boolean().optional(),
});
export async function GET(request: Request) {
try {
const user = await requireAuth();
if (!db) {
return NextResponse.json({ notifications: [] });
}
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '30'), 50);
const unreadOnly = searchParams.get('unread') === 'true';
const conditions = [eq(notifications.userId, user.id)];
if (unreadOnly) {
conditions.push(isNull(notifications.readAt));
}
const rows = await db.query.notifications.findMany({
where: and(...conditions),
with: {
actor: true,
post: true,
},
orderBy: [desc(notifications.createdAt)],
limit,
});
type ActorInfo = { id: string; handle: string; displayName: string | null; avatarUrl: string | null };
type PostInfo = { id: string; content: string };
const payload = rows.map((row) => {
const actor = row.actor as ActorInfo | null;
const post = row.post as PostInfo | null;
return {
id: row.id,
type: row.type,
createdAt: row.createdAt,
readAt: row.readAt,
actor: actor ? {
id: actor.id,
handle: actor.handle,
displayName: actor.displayName,
avatarUrl: actor.avatarUrl,
} : null,
post: post ? {
id: post.id,
content: post.content,
} : null,
};
});
return NextResponse.json({ notifications: payload });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Notifications fetch error:', error);
return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await requireAuth();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = markSchema.parse(body);
if (!data.all && (!data.ids || data.ids.length === 0)) {
return NextResponse.json({ error: 'No notifications specified' }, { status: 400 });
}
const where = data.all
? eq(notifications.userId, user.id)
: and(
eq(notifications.userId, user.id),
inArray(notifications.id, data.ids || [])
);
await db.update(notifications)
.set({ readAt: new Date() })
.where(where);
return NextResponse.json({ success: true });
} 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 });
}
console.error('Notifications update error:', error);
return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
}
}
+235
View File
@@ -0,0 +1,235 @@
import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
function extractSwarmDomain(apId: string | null): string | null {
if (!apId?.startsWith('swarm:')) return null;
const parts = apId.split(':');
return parts.length >= 2 ? parts[1] : null;
}
/**
* Check if a post is from a swarm node (has swarm: prefix in apId)
*/
function isSwarmPost(apId: string | null): boolean {
return apId?.startsWith('swarm:') ?? false;
}
/**
* Extract the original post ID from a swarm apId
*/
function extractSwarmPostId(apId: string): string | null {
const parts = apId.split(':');
return parts.length >= 3 ? parts[2] : null;
}
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
// Check if post exists
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Check if already liked
const existingLike = await db.query.likes.findFirst({
where: and(
eq(likes.userId, user.id),
eq(likes.postId, postId)
),
});
if (existingLike) {
return NextResponse.json({ error: 'Already liked' }, { status: 400 });
}
// Create like
await db.insert(likes).values({
userId: user.id,
postId,
});
// Update post's like count
await db.update(posts)
.set({ likesCount: post.likesCount + 1 })
.where(eq(posts.id, postId));
if (post.userId !== user.id) {
await db.insert(notifications).values({
userId: post.userId,
actorId: user.id,
postId,
type: 'like',
});
}
// SWARM-FIRST: Check if this is a swarm post and deliver directly
if (isSwarmPost(post.apId)) {
const targetDomain = extractSwarmDomain(post.apId);
const originalPostId = extractSwarmPostId(post.apId!);
if (targetDomain && originalPostId) {
(async () => {
try {
const { deliverSwarmLike } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmLike(targetDomain, {
postId: originalPostId,
like: {
actorHandle: user.handle,
actorDisplayName: user.displayName || user.handle,
actorAvatarUrl: user.avatarUrl || undefined,
actorNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
} else {
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
// Could fall back to ActivityPub here if needed
}
} catch (err) {
console.error('[Swarm] Error delivering like:', err);
}
})();
}
} else if (post.apId) {
// FALLBACK: Use ActivityPub for non-swarm posts
(async () => {
try {
const { createLikeActivity } = await import('@/lib/activitypub/activities');
const { deliverActivity } = await import('@/lib/activitypub/outbox');
// Get the post author's actor URL
const postWithAuthor = await db.query.posts.findFirst({
where: eq(posts.id, postId),
with: { author: true },
});
if (!postWithAuthor?.author) return;
const author = postWithAuthor.author as { handle: string };
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
} catch (err) {
console.error('[Federation] Error federating like:', err);
}
})();
}
return NextResponse.json({ success: true, liked: true });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
}
}
// Unlike a post
export async function DELETE(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
// Check if post exists
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Find the like
const existingLike = await db.query.likes.findFirst({
where: and(
eq(likes.userId, user.id),
eq(likes.postId, postId)
),
});
if (!existingLike) {
return NextResponse.json({ error: 'Not liked' }, { status: 400 });
}
// Remove like
await db.delete(likes).where(eq(likes.id, existingLike.id));
// Update post's like count
await db.update(posts)
.set({ likesCount: Math.max(0, post.likesCount - 1) })
.where(eq(posts.id, postId));
// SWARM-FIRST: Deliver unlike to swarm node
if (isSwarmPost(post.apId)) {
const targetDomain = extractSwarmDomain(post.apId);
const originalPostId = extractSwarmPostId(post.apId!);
if (targetDomain && originalPostId) {
(async () => {
try {
const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmUnlike(targetDomain, {
postId: originalPostId,
unlike: {
actorHandle: user.handle,
actorNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
console.log(`[Swarm] Unlike delivered to ${targetDomain}`);
} else {
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
}
} catch (err) {
console.error('[Swarm] Error delivering unlike:', err);
}
})();
}
}
return NextResponse.json({ success: true, liked: false });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
}
}
+222
View File
@@ -0,0 +1,222 @@
import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
function extractSwarmDomain(apId: string | null): string | null {
if (!apId?.startsWith('swarm:')) return null;
const parts = apId.split(':');
return parts.length >= 2 ? parts[1] : null;
}
/**
* Check if a post is from a swarm node
*/
function isSwarmPost(apId: string | null): boolean {
return apId?.startsWith('swarm:') ?? false;
}
/**
* Extract the original post ID from a swarm apId
*/
function extractSwarmPostId(apId: string): string | null {
const parts = apId.split(':');
return parts.length >= 3 ? parts[2] : null;
}
// Repost a post
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
// Check if post exists
const originalPost = await db.query.posts.findFirst({
where: eq(posts.id, postId),
});
if (!originalPost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (originalPost.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Check if already reposted by this user
const existingRepost = await db.query.posts.findFirst({
where: and(
eq(posts.userId, user.id),
eq(posts.repostOfId, postId),
eq(posts.isRemoved, false)
),
});
if (existingRepost) {
return NextResponse.json({ error: 'Already reposted' }, { status: 400 });
}
// Create repost
const repostId = crypto.randomUUID();
const [repost] = await db.insert(posts).values({
userId: user.id,
content: '', // Reposts don't have their own content
repostOfId: postId,
apId: `https://${nodeDomain}/posts/${repostId}`,
apUrl: `https://${nodeDomain}/posts/${repostId}`,
}).returning();
// Update original post's repost count
await db.update(posts)
.set({ repostsCount: originalPost.repostsCount + 1 })
.where(eq(posts.id, postId));
// Update user's post count
await db.update(users)
.set({ postsCount: user.postsCount + 1 })
.where(eq(users.id, user.id));
if (originalPost.userId !== user.id) {
await db.insert(notifications).values({
userId: originalPost.userId,
actorId: user.id,
postId,
type: 'repost',
});
}
// SWARM-FIRST: Deliver repost to swarm node
if (isSwarmPost(originalPost.apId)) {
const targetDomain = extractSwarmDomain(originalPost.apId);
const originalPostIdOnRemote = extractSwarmPostId(originalPost.apId!);
if (targetDomain && originalPostIdOnRemote) {
(async () => {
try {
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmRepost(targetDomain, {
postId: originalPostIdOnRemote,
repost: {
actorHandle: user.handle,
actorDisplayName: user.displayName || user.handle,
actorAvatarUrl: user.avatarUrl || undefined,
actorNodeDomain: nodeDomain,
repostId: repost.id,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
console.log(`[Swarm] Repost delivered to ${targetDomain}`);
} else {
console.warn(`[Swarm] Repost delivery failed: ${result.error}`);
}
} catch (err) {
console.error('[Swarm] Error delivering repost:', err);
}
})();
}
} else if (originalPost.apId) {
// FALLBACK: Use ActivityPub for non-swarm posts
(async () => {
try {
const { createAnnounceActivity } = await import('@/lib/activitypub/activities');
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
// Send Announce to our followers
const followerInboxes = await getFollowerInboxes(user.id);
if (followerInboxes.length > 0) {
const announceActivity = createAnnounceActivity(
user,
originalPost.apId!,
nodeDomain,
repost.id
);
const privateKey = user.privateKeyEncrypted;
if (privateKey) {
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId);
console.log(`[Federation] Announce for ${originalPost.apId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
}
}
} catch (err) {
console.error('[Federation] Error federating repost:', err);
}
})();
}
return NextResponse.json({ success: true, repost, reposted: true });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to repost' }, { status: 500 });
}
}
// Unrepost a post
export async function DELETE(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
// Check if original post exists
const originalPost = await db.query.posts.findFirst({
where: eq(posts.id, postId),
});
// Find the repost by this user
const repost = await db.query.posts.findFirst({
where: and(
eq(posts.userId, user.id),
eq(posts.repostOfId, postId),
eq(posts.isRemoved, false)
),
});
if (!repost) {
return NextResponse.json({ error: 'Not reposted' }, { status: 400 });
}
// Mark repost as removed
await db.update(posts)
.set({ isRemoved: true })
.where(eq(posts.id, repost.id));
// Update original post's repost count
if (originalPost) {
await db.update(posts)
.set({ repostsCount: Math.max(0, originalPost.repostsCount - 1) })
.where(eq(posts.id, postId));
}
// Update user's post count
await db.update(users)
.set({ postsCount: Math.max(0, user.postsCount - 1) })
.where(eq(users.id, user.id));
return NextResponse.json({ success: true, reposted: false });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to unrepost' }, { status: 500 });
}
}
+206
View File
@@ -0,0 +1,206 @@
import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { fetchRemotePost } from '@/lib/activitypub/fetchRemotePost';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let mainPost: any = null;
let replyPosts: any[] = [];
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
author: true,
media: true,
replyTo: {
with: { author: true },
},
},
});
if (post) {
mainPost = post;
const replies = await db.query.posts.findMany({
where: and(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
});
let allPostIds = [post.id, ...replies.map(r => r.id)];
try {
const { requireAuth } = await import('@/lib/auth');
const { likes } = await import('@/db');
const { inArray } = await import('drizzle-orm');
const viewer = await requireAuth();
allPostIds = [post.id, ...replies.map(r => r.id)];
if (allPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, allPostIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, allPostIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
mainPost = {
...post,
isLiked: likedPostIds.has(post.id),
isReposted: repostedPostIds.has(post.id),
} as any;
replyPosts = replies.map(r => ({
...r,
isLiked: likedPostIds.has(r.id),
isReposted: repostedPostIds.has(r.id),
})) as any[];
}
} catch {
}
} else {
const cached = await db.query.remotePosts.findFirst({
where: eq(remotePosts.apId, id),
});
if (cached) {
mainPost = {
id: cached.id,
content: cached.content,
createdAt: cached.publishedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: {
id: cached.authorHandle,
handle: cached.authorHandle,
displayName: cached.authorDisplayName || cached.authorHandle,
avatarUrl: cached.authorAvatarUrl,
bio: null,
isRemote: true,
},
media: cached.mediaJson ? JSON.parse(cached.mediaJson) : null,
linkPreviewUrl: cached.linkPreviewUrl,
linkPreviewTitle: cached.linkPreviewTitle,
linkPreviewDescription: cached.linkPreviewDescription,
linkPreviewImage: cached.linkPreviewImage,
isLiked: false,
isReposted: false,
};
} else {
const postUrl = `https://${nodeDomain}/posts/${id}`;
const result = await fetchRemotePost(postUrl, nodeDomain);
if (result.post) {
mainPost = result.post;
}
}
}
if (!mainPost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
return NextResponse.json({
post: mainPost,
replies: replyPosts,
});
} catch (error) {
console.error('Get post detail error:', error);
return NextResponse.json(
{ error: 'Failed to get post detail' },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { requireAuth } = await import('@/lib/auth');
const { bots } = await import('@/db');
const user = await requireAuth();
const { id } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
bot: true,
},
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Allow deletion if user owns the post OR if user owns the bot that made the post
const isPostOwner = post.userId === user.id;
const isBotOwner = post.bot && post.bot.ownerId === user.id;
if (!isPostOwner && !isBotOwner) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// 1. If it's a reply, decrement parent's repliesCount
if (post.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, post.replyToId),
});
if (parentPost && parentPost.repliesCount > 0) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount - 1 })
.where(eq(posts.id, post.replyToId));
}
}
// 2. Delete the post (cascades to media, likes, notifications)
await db.delete(posts).where(eq(posts.id, id));
// 3. Decrement the post author's postsCount
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
});
if (postAuthor && postAuthor.postsCount > 0) {
await db.update(users)
.set({ postsCount: postAuthor.postsCount - 1 })
.where(eq(users.id, post.userId));
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete post error:', error);
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Failed to delete post' },
{ status: 500 }
);
}
}
+620
View File
@@ -0,0 +1,620 @@
import { NextResponse } from 'next/server';
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { z } from 'zod';
const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72;
const CURATION_SEED_MULTIPLIER = 5;
const CURATION_SEED_CAP = 200;
const buildWhere = (...conditions: Array<SQL | undefined>) => {
const filtered = conditions.filter(Boolean) as SQL[];
if (filtered.length === 0) return undefined;
return and(...filtered);
};
const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
swarmReplyTo: z.object({
postId: z.string(),
nodeDomain: z.string(),
}).optional(),
mediaIds: z.array(z.string().uuid()).max(4).optional(),
isNsfw: z.boolean().optional(),
linkPreview: z.object({
url: z.string().url(),
title: z.string().optional(),
description: z.string().optional(),
image: z.string().url().optional().nullable(),
}).optional().nullable(),
});
// Create a new post
export async function POST(request: Request) {
try {
const user = await requireAuth();
const body = await request.json();
const data = createPostSchema.parse(body);
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const [post] = await db.insert(posts).values({
userId: user.id,
content: data.content,
replyToId: data.replyToId,
isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
linkPreviewUrl: data.linkPreview?.url,
linkPreviewTitle: data.linkPreview?.title,
linkPreviewDescription: data.linkPreview?.description,
linkPreviewImage: data.linkPreview?.image,
}).returning();
let attachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
await db.update(media)
.set({ postId: post.id })
.where(and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
isNull(media.postId),
));
attachedMedia = await db.query.media.findMany({
where: and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
eq(media.postId, post.id),
),
});
}
// Update user's post count
await db.update(users)
.set({ postsCount: user.postsCount + 1 })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count
if (data.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, data.replyToId),
});
if (parentPost) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount + 1 })
.where(eq(posts.id, data.replyToId));
}
}
// If this is a reply to a swarm post, deliver it to the origin node
if (data.swarmReplyTo) {
(async () => {
try {
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo!.postId,
reply: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
},
nodeDomain,
mediaUrls: attachedMedia.map(m => m.url),
},
};
const response = await fetch(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(replyPayload),
});
if (response.ok) {
console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`);
} else {
console.error(`[Swarm] Failed to deliver reply: ${response.status}`);
}
} catch (err) {
console.error('[Swarm] Error delivering reply:', err);
}
})();
}
// SWARM-FIRST: Deliver mentions to swarm nodes
(async () => {
try {
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions(
data.content,
post.id,
{
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
nodeDomain,
}
);
if (result.delivered > 0) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
}
} catch (err) {
console.error('[Swarm] Error delivering mentions:', err);
}
})();
// Federate the post to remote followers (non-blocking)
(async () => {
try {
// SWARM-FIRST: Deliver to swarm followers directly
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
const swarmResult = await deliverPostToSwarmFollowers(
user.id,
post,
{
handle: user.handle,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isNsfw: user.isNsfw,
},
attachedMedia,
nodeDomain
);
if (swarmResult.delivered > 0) {
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
}
// FALLBACK: Deliver to ActivityPub followers
const { createCreateActivity } = await import('@/lib/activitypub/activities');
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
const followerInboxes = await getFollowerInboxes(user.id);
if (followerInboxes.length === 0) {
return; // No remote followers to notify
}
const createActivity = createCreateActivity(post, user, nodeDomain);
const privateKey = user.privateKeyEncrypted;
if (!privateKey) {
console.error('[Federation] User has no private key for signing');
return;
}
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
const result = await deliverToFollowers(createActivity, followerInboxes, privateKey, keyId);
console.log(`[Federation] Post ${post.id} delivered to ${result.delivered}/${followerInboxes.length} inboxes (${result.failed} failed)`);
} catch (err) {
console.error('[Federation] Error federating post:', err);
}
})();
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
} catch (error) {
console.error('Create post error:', 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 create post' },
{ status: 500 }
);
}
}
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
const normalizeForDedup = (content: string): string => {
return content
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like &lsquo;
.replace(/&#\d+;/g, '') // Remove numeric entities
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
.replace(/[^\w\s]/g, '') // Remove punctuation
.replace(/\s+/g, ' ') // Normalize whitespace
.toLowerCase()
.trim()
.slice(0, 50); // Compare first 50 chars (article title)
};
// Helper to transform cached remote posts to match local post format
// Deduplicates by apId AND by similar content from same author
const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]) => {
const seenApIds = new Set<string>();
const seenContentKeys = new Set<string>(); // author+normalizedContent
const uniquePosts: typeof remotePosts.$inferSelect[] = [];
for (const rp of remotePostsData) {
if (seenApIds.has(rp.apId)) continue;
// Content-based dedup: same author + similar content = skip
const contentKey = `${rp.authorHandle}:${normalizeForDedup(rp.content)}`;
if (seenContentKeys.has(contentKey)) continue;
seenApIds.add(rp.apId);
seenContentKeys.add(contentKey);
uniquePosts.push(rp);
}
return uniquePosts.map(rp => {
const mediaData = rp.mediaJson ? JSON.parse(rp.mediaJson) : [];
return {
id: rp.id,
content: rp.content,
createdAt: rp.publishedAt,
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
isRemote: true,
apId: rp.apId,
linkPreviewUrl: rp.linkPreviewUrl,
linkPreviewTitle: rp.linkPreviewTitle,
linkPreviewDescription: rp.linkPreviewDescription,
linkPreviewImage: rp.linkPreviewImage,
author: {
id: rp.authorActorUrl,
handle: rp.authorHandle,
displayName: rp.authorDisplayName,
avatarUrl: rp.authorAvatarUrl,
isRemote: true,
},
media: mediaData.map((m: { url: string; altText?: string }, idx: number) => ({
id: `${rp.id}-media-${idx}`,
url: m.url,
altText: m.altText || null,
})),
replyTo: null,
};
});
};
// Get timeline / feed
export async function GET(request: Request) {
try {
// Return empty posts if no database is connected (for UI testing)
if (!db) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const { searchParams } = new URL(request.url);
const type = searchParams.get('type') || 'home'; // home, public, user, curated
const userId = searchParams.get('userId');
const cursor = searchParams.get('cursor');
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
let feedPosts;
const baseFilter = buildWhere(
eq(posts.isRemoved, false)
);
if (type === 'local') {
// Local node posts only - no fediverse content
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
} else if (type === 'public') {
// Public timeline - all local posts + all cached remote posts
const localPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit: limit * 2,
});
// Get all cached remote posts
const remotePostsData = await db.query.remotePosts.findMany({
orderBy: [desc(remotePosts.publishedAt)],
limit: limit,
});
const transformedRemote = transformRemotePosts(remotePostsData);
// Merge and sort by date
feedPosts = [...localPosts, ...transformedRemote]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit) as any;
} else if (type === 'user' && userId) {
// User's posts
feedPosts = await db.query.posts.findMany({
where: buildWhere(baseFilter, eq(posts.userId, userId)),
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
} else if (type === 'curated') {
// Curated feed - swarm posts only (no fediverse)
let viewer = null;
let includeNsfw = false;
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
viewer = session?.user || null;
includeNsfw = session?.user?.nsfwEnabled ?? false;
} catch {
viewer = null;
includeNsfw = false;
}
// Fetch swarm posts with user's NSFW preference
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
// Transform swarm posts to match local post format
const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies
content: sp.content,
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.media?.map((m, idx) => ({
id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`,
url: m.url,
altText: m.altText || null,
mimeType: m.mimeType || null,
})) || [],
linkPreviewUrl: sp.linkPreviewUrl || null,
linkPreviewTitle: sp.linkPreviewTitle || null,
linkPreviewDescription: sp.linkPreviewDescription || null,
linkPreviewImage: sp.linkPreviewImage || null,
replyTo: null,
}));
let mutedIds = new Set<string>();
let blockedIds = new Set<string>();
if (viewer) {
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
.from(mutes)
.where(eq(mutes.userId, viewer.id));
mutedIds = new Set(muteRows.map(row => row.mutedUserId));
const blockRows = await db.select({ blockedUserId: blocks.blockedUserId })
.from(blocks)
.where(eq(blocks.userId, viewer.id));
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
}
const now = Date.now();
const rankedPosts = swarmPosts
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
.map((post: any) => {
const createdAt = new Date(post.createdAt).getTime();
const ageHours = Math.max(0, (now - createdAt) / 3600000);
const engagement = (post.likesCount || 0) + (post.repostsCount || 0) * 2 + (post.repliesCount || 0) * 0.5;
const engagementScore = Math.log1p(Math.max(0, engagement));
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
const score = engagementScore * 1.4 + recencyScore * 1.1;
const reasons: string[] = [];
reasons.push(`From ${post.nodeDomain}`);
if (engagement >= 5) {
reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`);
} else if ((post.repliesCount || 0) > 0) {
reasons.push(`Active conversation: ${post.repliesCount} replies`);
}
if (ageHours <= 6) {
reasons.push('Posted recently');
} else if (ageHours <= 24) {
reasons.push('Posted today');
}
if (reasons.length === 1) {
reasons.push('New post');
}
return {
...post,
feedMeta: {
score: Number(score.toFixed(3)),
reasons,
engagement: {
likes: post.likesCount || 0,
reposts: post.repostsCount || 0,
replies: post.repliesCount || 0,
},
},
};
})
.sort((a: any, b: any) => {
if (b.feedMeta.score !== a.feedMeta.score) {
return b.feedMeta.score - a.feedMeta.score;
}
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
})
.slice(0, limit);
feedPosts = rankedPosts;
} else {
// Home timeline - need auth
try {
const user = await requireAuth();
// Get IDs of users the current user follows
const followRows = await db.select({ followingId: follows.followingId })
.from(follows)
.where(eq(follows.followerId, user.id));
const followingIds = followRows.map(row => row.followingId);
// Include own posts + posts from followed users
const allowedUserIds = [user.id, ...followingIds];
// Get local posts from people the user follows + their own posts
const localPosts = await db.query.posts.findMany({
where: buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)),
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit: limit * 2, // Get more to account for mixing with remote
});
// Get handles of remote users we follow
const followedRemoteUsers = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
});
const followedRemoteHandles = followedRemoteUsers.map(f => f.targetHandle);
// Get cached remote posts from followed users
let remotePostsData: typeof remotePosts.$inferSelect[] = [];
if (followedRemoteHandles.length > 0) {
remotePostsData = await db.query.remotePosts.findMany({
where: inArray(remotePosts.authorHandle, followedRemoteHandles),
orderBy: [desc(remotePosts.publishedAt)],
limit: limit,
});
}
// Transform remote posts to match local post format (with deduplication)
const transformedRemotePosts = transformRemotePosts(remotePostsData);
// Merge and sort by date
const allPosts = [...localPosts, ...transformedRemotePosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
feedPosts = allPosts as any;
} catch {
// Not authenticated, return public timeline
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
}
}
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user;
const postIds = feedPosts.map((p: { id: string }) => p.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
feedPosts = feedPosts.map((p: { id: string }) => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
})) as any;
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
return NextResponse.json({
posts: feedPosts || [],
meta: type === 'curated' ? {
algorithm: 'curated-v1',
windowHours: CURATION_WINDOW_HOURS,
seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP),
weights: {
engagement: 1.4,
recency: 1.1,
followBoost: 0.9,
selfBoost: 0.5,
},
} : undefined,
nextCursor: (feedPosts?.length === limit) ? feedPosts[feedPosts.length - 1]?.id : null,
});
} catch (error) {
console.error('Get feed error details:', error);
return NextResponse.json(
{ error: 'Failed to get feed' },
{ status: 500 }
);
}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Swarm Posts Endpoint
*
* GET: Returns aggregated posts from across the swarm
*/
import { NextRequest, NextResponse } from 'next/server';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth';
/**
* GET /api/posts/swarm
*
* Returns aggregated posts from across the swarm network.
* NSFW content is included based on user's nsfwEnabled setting.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true';
// Check user's NSFW preference
let includeNsfw = false;
try {
const session = await getSession();
includeNsfw = session?.user?.nsfwEnabled ?? false;
} catch {
includeNsfw = false;
}
// Fetch swarm timeline (no caching - user preferences vary)
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw });
return NextResponse.json({
posts: timeline.posts,
sources: timeline.sources,
cached: false,
fetchedAt: timeline.fetchedAt,
// Debug info
debug: {
includeNsfw,
sourceCount: timeline.sources.length,
totalPostsBeforeFilter: timeline.sources.reduce((sum, s) => sum + s.postCount, 0),
postsAfterFilter: timeline.posts.length,
},
});
} catch (error) {
console.error('Swarm posts error:', error);
return NextResponse.json(
{ error: 'Failed to fetch swarm posts' },
{ status: 500 }
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { db, reports, posts, users } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const reportSchema = z.object({
targetType: z.enum(['post', 'user']),
targetId: z.string().uuid(),
reason: z.string().min(3).max(500),
});
export async function POST(request: Request) {
try {
const reporter = await requireAuth();
if (reporter.isSuspended || reporter.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = reportSchema.parse(body);
if (data.targetType === 'post') {
const targetPost = await db.query.posts.findFirst({
where: eq(posts.id, data.targetId),
});
if (!targetPost || targetPost.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
}
if (data.targetType === 'user') {
const targetUser = await db.query.users.findFirst({
where: eq(users.id, data.targetId),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
}
const [report] = await db.insert(reports).values({
reporterId: reporter.id,
targetType: data.targetType,
targetId: data.targetId,
reason: data.reason,
status: 'open',
}).returning();
return NextResponse.json({ report });
} 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 });
}
console.error('Report error:', error);
return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 });
}
}
+206
View File
@@ -0,0 +1,206 @@
import { NextResponse } from 'next/server';
import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type SearchUser = {
id: string;
handle: string;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
profileUrl?: string | null;
isRemote?: boolean;
isBot?: boolean;
};
const decodeEntities = (value: string) =>
value
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&#(\\d+);/g, (_, num) => String.fromCharCode(Number(num)))
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
const sanitizeText = (value?: string | null) => {
if (!value) return null;
const withoutTags = value.replace(/<[^>]*>/g, ' ');
const decoded = decodeEntities(withoutTags);
return decoded.replace(/\\s+/g, ' ').trim() || null;
};
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
let trimmed = query.trim();
if (!trimmed) return null;
if (trimmed.startsWith('acct:')) {
trimmed = trimmed.slice(5);
}
const withoutPrefix = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
if (withoutPrefix.includes(' ')) return null;
const parts = withoutPrefix.split('@').filter(Boolean);
if (parts.length !== 2) return null;
const [handle, domain] = parts;
if (!handle || !domain) return null;
if (!domain.includes('.') && !domain.includes(':')) return null;
return { handle: handle.toLowerCase(), domain: domain.toLowerCase() };
};
const buildRemoteUser = (
profile: Awaited<ReturnType<typeof resolveRemoteUser>>,
handle: string,
domain: string,
): SearchUser | null => {
if (!profile) return null;
const displayName = sanitizeText(profile.name) || sanitizeText(profile.preferredUsername) || null;
const username = profile.preferredUsername || handle;
if (!username) return null;
const fullHandle = `${username}@${domain}`.replace(/^@/, '');
const iconUrl = typeof profile.icon === 'string' ? profile.icon : profile.icon?.url;
const profileUrl = typeof profile.url === 'string' ? profile.url : profile.id;
return {
id: profile.id || profileUrl || `remote:${fullHandle}`,
handle: fullHandle,
displayName,
avatarUrl: iconUrl ?? null,
bio: sanitizeText(profile.summary),
profileUrl: profileUrl ?? null,
isRemote: true,
};
};
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
const type = searchParams.get('type') || 'all'; // all, users, posts
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
if (!query.trim()) {
return NextResponse.json({ users: [], posts: [] });
}
// Return empty if no database
if (!db) {
return NextResponse.json({
users: [],
posts: [],
message: 'Search requires database connection'
});
}
const searchPattern = `%${query}%`;
let searchUsers: SearchUser[] = [];
let searchPosts: typeof posts.$inferSelect[] = [];
// Search users
if (type === 'all' || type === 'users') {
const userConditions = and(
or(
ilike(users.handle, searchPattern),
ilike(users.displayName, searchPattern),
ilike(users.bio, searchPattern)
),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
);
searchUsers = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
isBot: users.isBot,
})
.from(users)
.where(userConditions)
.limit(limit);
}
// Federated user lookup (exact handle@domain queries)
if ((type === 'all' || type === 'users') && searchUsers.length < limit) {
const parsedRemote = parseRemoteHandleQuery(query);
if (parsedRemote) {
const remoteProfile = await resolveRemoteUser(parsedRemote.handle, parsedRemote.domain);
const remoteUser = buildRemoteUser(remoteProfile, parsedRemote.handle, parsedRemote.domain);
if (remoteUser && !searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
}
}
}
const moderatedUsers = await db.select({ id: users.id })
.from(users)
.where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
const moderatedIds = moderatedUsers.map((item) => item.id);
// Search posts
if (type === 'all' || type === 'posts') {
const postConditions = [
ilike(posts.content, searchPattern),
eq(posts.isRemoved, false),
];
if (moderatedIds.length) {
postConditions.push(notInArray(posts.userId, moderatedIds));
}
const postResults = await db.query.posts.findMany({
where: and(...postConditions),
with: {
author: true,
media: true,
bot: true,
},
orderBy: [desc(posts.createdAt)],
limit,
});
searchPosts = postResults;
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && searchPosts.length > 0) {
const viewer = session.user;
const postIds = searchPosts.map(p => p.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
searchPosts = searchPosts.map(p => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
})) as any;
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
}
return NextResponse.json({
users: searchUsers,
posts: searchPosts,
});
} catch (error) {
console.error('Search error:', error);
return NextResponse.json({ error: 'Search failed' }, { 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', 'interactions'])).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', 'interactions'])).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 }
);
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Swarm Inbox Endpoint
*
* POST: Receive posts from users on other swarm nodes that local users follow
*
* This is the swarm equivalent of ActivityPub inbox - when a user on another
* Synapsis node creates a post, it gets pushed here for their followers.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const swarmPostSchema = z.object({
post: z.object({
id: z.string(),
content: z.string(),
createdAt: z.string(),
isNsfw: z.boolean(),
replyToId: z.string().optional(),
repostOfId: z.string().optional(),
media: z.array(z.object({
url: z.string(),
mimeType: z.string().optional(),
altText: z.string().optional(),
})).optional(),
linkPreviewUrl: z.string().optional(),
linkPreviewTitle: z.string().optional(),
linkPreviewDescription: z.string().optional(),
linkPreviewImage: z.string().optional(),
}),
author: z.object({
handle: z.string(),
displayName: z.string(),
avatarUrl: z.string().optional(),
isNsfw: z.boolean(),
}),
nodeDomain: z.string(),
timestamp: z.string(),
});
/**
* POST /api/swarm/inbox
*
* Receives a post from another swarm node.
* Stores it for local users who follow the author.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmPostSchema.parse(body);
// Construct the swarm post ID
const swarmPostId = `swarm:${data.nodeDomain}:${data.post.id}`;
// Check if we already have this post
const existingPost = await db.query.posts.findFirst({
where: eq(posts.apId, swarmPostId),
});
if (existingPost) {
return NextResponse.json({
success: true,
message: 'Post already exists',
});
}
// Check if anyone on this node follows the author
const authorActorUrl = `swarm://${data.nodeDomain}/${data.author.handle}`;
const hasFollowers = await db.query.remoteFollowers.findFirst({
where: eq(remoteFollowers.actorUrl, authorActorUrl),
});
// Even if no one follows, we might want to cache for timeline
// For now, only store if someone follows
if (!hasFollowers) {
return NextResponse.json({
success: true,
message: 'No local followers',
});
}
// Get or create placeholder user for the remote author
const remoteHandle = `${data.author.handle}@${data.nodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.nodeDomain}:${data.author.handle}`,
handle: remoteHandle,
displayName: data.author.displayName,
avatarUrl: data.author.avatarUrl || null,
isNsfw: data.author.isNsfw,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
} else {
// Update profile info if changed
await db.update(users)
.set({
displayName: data.author.displayName,
avatarUrl: data.author.avatarUrl || remoteUser.avatarUrl,
isNsfw: data.author.isNsfw,
})
.where(eq(users.id, remoteUser.id));
}
// Create the post
const [newPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.post.content,
isNsfw: data.post.isNsfw || data.author.isNsfw,
apId: swarmPostId,
apUrl: `https://${data.nodeDomain}/${data.author.handle}/posts/${data.post.id}`,
createdAt: new Date(data.post.createdAt),
linkPreviewUrl: data.post.linkPreviewUrl || null,
linkPreviewTitle: data.post.linkPreviewTitle || null,
linkPreviewDescription: data.post.linkPreviewDescription || null,
linkPreviewImage: data.post.linkPreviewImage || null,
}).returning();
// Store media attachments
if (data.post.media && data.post.media.length > 0) {
for (const m of data.post.media) {
await db.insert(media).values({
userId: remoteUser.id,
postId: newPost.id,
url: m.url,
mimeType: m.mimeType || null,
altText: m.altText || null,
});
}
}
console.log(`[Swarm] Received post from ${remoteHandle}: ${newPost.id}`);
return NextResponse.json({
success: true,
message: 'Post received',
postId: newPost.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Inbox error:', error);
return NextResponse.json({ error: 'Failed to process post' }, { 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 }
);
}
}
@@ -0,0 +1,126 @@
/**
* Swarm Follow Endpoint
*
* POST: Receive a follow from another swarm node
*
* This enables swarm-native follows between Synapsis nodes,
* bypassing ActivityPub for faster, more direct connections.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const swarmFollowSchema = z.object({
targetHandle: z.string(),
follow: z.object({
followerHandle: z.string(),
followerDisplayName: z.string(),
followerAvatarUrl: z.string().optional(),
followerBio: z.string().optional(),
followerNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/follow
*
* Receives a follow from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmFollowSchema.parse(body);
// Find the target user (local user being followed)
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, data.targetHandle.toLowerCase()),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Construct the remote follower's actor URL (swarm-style)
const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`;
const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
// Check if this follow already exists
const existingFollow = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, actorUrl)
),
});
if (existingFollow) {
return NextResponse.json({
success: true,
message: 'Already following',
});
}
// Create the remote follower record
await db.insert(remoteFollowers).values({
userId: targetUser.id,
actorUrl,
inboxUrl,
handle: remoteHandle,
activityId: data.follow.interactionId,
});
// Update follower count
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.where(eq(users.id, targetUser.id));
// Get or create placeholder user for the remote follower (for notifications)
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.follow.followerNodeDomain}:${data.follow.followerHandle}`,
handle: remoteHandle,
displayName: data.follow.followerDisplayName,
avatarUrl: data.follow.followerAvatarUrl || null,
bio: data.follow.followerBio || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Create notification
await db.insert(notifications).values({
userId: targetUser.id,
actorId: remoteUser.id,
type: 'follow',
});
console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`);
return NextResponse.json({
success: true,
message: 'Follow received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Follow error:', error);
return NextResponse.json({ error: 'Failed to process follow' }, { status: 500 });
}
}
@@ -0,0 +1,100 @@
/**
* Swarm Like Endpoint
*
* POST: Receive a like from another swarm node
*
* This is the swarm-first approach - direct node-to-node communication
* for likes, bypassing ActivityPub for Synapsis nodes.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
like: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/like
*
* Receives a like from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmLikeSchema.parse(body);
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
with: { author: true },
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Increment like count
await db.update(posts)
.set({ likesCount: post.likesCount + 1 })
.where(eq(posts.id, data.postId));
// Create a notification for the post author
// First, get or create a placeholder user for the remote liker
const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
// Create a placeholder user for the remote actor
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`,
handle: remoteHandle,
displayName: data.like.actorDisplayName,
avatarUrl: data.like.actorAvatarUrl || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Create notification
await db.insert(notifications).values({
userId: post.userId,
actorId: remoteUser.id,
postId: data.postId,
type: 'like',
});
console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`);
return NextResponse.json({
success: true,
message: 'Like received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Like error:', error);
return NextResponse.json({ error: 'Failed to process like' }, { status: 500 });
}
}
@@ -0,0 +1,110 @@
/**
* Swarm Mention Endpoint
*
* POST: Receive a mention notification from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications, posts } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmMentionSchema = z.object({
mentionedHandle: z.string(),
mention: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
postId: z.string(),
postContent: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/mention
*
* Receives a mention notification from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmMentionSchema.parse(body);
// Find the mentioned user (local user)
const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, data.mentionedHandle.toLowerCase()),
});
if (!mentionedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (mentionedUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get or create placeholder user for the remote actor
const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.mention.actorNodeDomain}:${data.mention.actorHandle}`,
handle: remoteHandle,
displayName: data.mention.actorDisplayName,
avatarUrl: data.mention.actorAvatarUrl || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Check if we already have this post cached (from swarm timeline)
// If not, create a placeholder post for the notification
const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`;
let post = await db.query.posts.findFirst({
where: eq(posts.apId, swarmPostId),
});
if (!post) {
// Create a placeholder post for the mention
const [newPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.mention.postContent,
apId: swarmPostId,
apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`,
createdAt: new Date(data.mention.timestamp),
}).returning();
post = newPost;
}
// Create notification
await db.insert(notifications).values({
userId: mentionedUser.id,
actorId: remoteUser.id,
postId: post.id,
type: 'mention',
});
console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`);
return NextResponse.json({
success: true,
message: 'Mention received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Mention error:', error);
return NextResponse.json({ error: 'Failed to process mention' }, { status: 500 });
}
}
@@ -0,0 +1,95 @@
/**
* Swarm Repost Endpoint
*
* POST: Receive a repost from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
repost: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
repostId: z.string(), // The ID of the repost on the actor's node
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/repost
*
* Receives a repost notification from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmRepostSchema.parse(body);
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Increment repost count
await db.update(posts)
.set({ repostsCount: post.repostsCount + 1 })
.where(eq(posts.id, data.postId));
// Get or create placeholder user for the remote reposter
const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.repost.actorNodeDomain}:${data.repost.actorHandle}`,
handle: remoteHandle,
displayName: data.repost.actorDisplayName,
avatarUrl: data.repost.actorAvatarUrl || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Create notification
await db.insert(notifications).values({
userId: post.userId,
actorId: remoteUser.id,
postId: data.postId,
type: 'repost',
});
console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`);
return NextResponse.json({
success: true,
message: 'Repost received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Repost error:', error);
return NextResponse.json({ error: 'Failed to process repost' }, { status: 500 });
}
}
@@ -0,0 +1,83 @@
/**
* Swarm Unfollow Endpoint
*
* POST: Receive an unfollow from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const swarmUnfollowSchema = z.object({
targetHandle: z.string(),
unfollow: z.object({
followerHandle: z.string(),
followerNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/unfollow
*
* Receives an unfollow from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmUnfollowSchema.parse(body);
// Find the target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, data.targetHandle.toLowerCase()),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Find and remove the remote follower record
const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`;
const existingFollow = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, actorUrl)
),
});
if (!existingFollow) {
return NextResponse.json({
success: true,
message: 'Not following',
});
}
// Remove the follow
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollow.id));
// Update follower count
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.where(eq(users.id, targetUser.id));
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
return NextResponse.json({
success: true,
message: 'Unfollow received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Unfollow error:', error);
return NextResponse.json({ error: 'Failed to process unfollow' }, { status: 500 });
}
}
@@ -0,0 +1,63 @@
/**
* Swarm Unlike Endpoint
*
* POST: Receive an unlike from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmUnlikeSchema = z.object({
postId: z.string().uuid(),
unlike: z.object({
actorHandle: z.string(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/unlike
*
* Receives an unlike from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmUnlikeSchema.parse(body);
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Decrement like count (don't go below 0)
await db.update(posts)
.set({ likesCount: Math.max(0, post.likesCount - 1) })
.where(eq(posts.id, data.postId));
console.log(`[Swarm] Received unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain} on post ${data.postId}`);
return NextResponse.json({
success: true,
message: 'Unlike received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Unlike error:', error);
return NextResponse.json({ error: 'Failed to process unlike' }, { 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 }
);
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Swarm Post Endpoint
*
* GET: Returns a single post for swarm requests
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media } from '@/db';
import { eq } from 'drizzle-orm';
type RouteContext = { params: Promise<{ id: string }> };
/**
* GET /api/swarm/posts/[id]
*
* Returns a single post with author info.
* Used by other nodes to fetch post details.
*/
export async function GET(request: NextRequest, context: RouteContext) {
try {
const { id: postId } = await context.params;
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Find the post with author
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
with: { author: true },
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Get media for the post
const postMedia = await db
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
.from(media)
.where(eq(media.postId, postId));
const author = post.author as {
handle: string;
displayName: string | null;
avatarUrl: string | null;
isNsfw: boolean;
};
return NextResponse.json({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isNsfw: author.isNsfw,
},
nodeDomain,
isNsfw: post.isNsfw || author.isNsfw,
likesCount: post.likesCount,
repostsCount: post.repostsCount,
repliesCount: post.repliesCount,
media: postMedia.length > 0 ? postMedia.map(m => ({
url: m.url,
mimeType: m.mimeType || undefined,
altText: m.altText || undefined,
})) : undefined,
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
replyToId: post.replyToId || undefined,
repostOfId: post.repostOfId || undefined,
});
} catch (error) {
console.error('Swarm post error:', error);
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
);
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Swarm Replies Endpoint
*
* POST: Receive a reply from another node
* GET: Fetch replies to a post on this node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
// Schema for incoming swarm reply
const swarmReplySchema = z.object({
postId: z.string().uuid(), // The local post being replied to
reply: z.object({
id: z.string(), // Original reply ID on the sender's node
content: z.string(),
createdAt: z.string(),
author: z.object({
handle: z.string(),
displayName: z.string(),
avatarUrl: z.string().optional(),
}),
nodeDomain: z.string(),
mediaUrls: z.array(z.string()).optional(),
}),
});
/**
* POST /api/swarm/replies
*
* Receives a reply from another node in the swarm.
* The reply is stored as a remote reply linked to the local post.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmReplySchema.parse(body);
// Verify the target post exists on this node
const targetPost = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!targetPost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Check if we already have this reply (by swarm ID)
const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`;
const existingReply = await db.query.posts.findFirst({
where: eq(posts.apId, swarmReplyId),
});
if (existingReply) {
return NextResponse.json({ success: true, message: 'Reply already exists' });
}
// We need a system user to attribute swarm replies to
// For now, we'll store them with metadata in the apId/apUrl fields
// and create a virtual representation
// Get or create a placeholder user for this remote author
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`),
});
if (!remoteUser) {
// Create a placeholder user for the remote author
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`,
handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`,
displayName: data.reply.author.displayName,
avatarUrl: data.reply.author.avatarUrl || null,
publicKey: 'swarm-remote-user', // Placeholder
}).returning();
remoteUser = newUser;
}
// Create the reply post
const [replyPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.reply.content,
replyToId: data.postId,
apId: swarmReplyId,
apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`,
createdAt: new Date(data.reply.createdAt),
}).returning();
// Update the parent post's reply count
await db.update(posts)
.set({ repliesCount: targetPost.repliesCount + 1 })
.where(eq(posts.id, data.postId));
console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`);
return NextResponse.json({
success: true,
replyId: replyPost.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Reply error:', error);
return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 });
}
}
/**
* GET /api/swarm/replies?postId=xxx
*
* Returns replies to a specific post on this node.
* Used by other nodes to fetch reply threads.
*/
export async function GET(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ replies: [] });
}
const { searchParams } = new URL(request.url);
const postId = searchParams.get('postId');
if (!postId) {
return NextResponse.json({ error: 'postId required' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Get replies to this post
const replies = await db
.select({
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(posts.replyToId, postId),
eq(posts.isRemoved, false)
)
)
.orderBy(desc(posts.createdAt))
.limit(50);
// Format replies for swarm consumption
const formattedReplies = replies.map(reply => ({
id: reply.id,
content: reply.content,
createdAt: reply.createdAt.toISOString(),
author: {
handle: reply.authorHandle.includes('@')
? reply.authorHandle.split('@')[0]
: reply.authorHandle,
displayName: reply.authorDisplayName || reply.authorHandle,
avatarUrl: reply.authorAvatarUrl || undefined,
},
nodeDomain: reply.authorHandle.includes('@')
? reply.authorHandle.split('@')[1]
: nodeDomain,
likeCount: reply.likesCount,
repostCount: reply.repostsCount,
replyCount: reply.repliesCount,
}));
return NextResponse.json({
postId,
replies: formattedReplies,
nodeDomain,
});
} catch (error) {
console.error('[Swarm] Fetch replies error:', error);
return NextResponse.json({ error: 'Failed to fetch replies' }, { status: 500 });
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 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;
media?: { url: string; mimeType?: string; altText?: string }[];
// Link preview
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: 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,
linkPreviewUrl: posts.linkPreviewUrl,
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
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, mimeType: media.mimeType, altText: media.altText })
.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, // Post-level NSFW (not node-level)
likeCount: post.likesCount,
repostCount: post.repostsCount,
replyCount: post.repliesCount,
media: postMedia.length > 0 ? postMedia.map(m => ({
url: m.url,
mimeType: m.mimeType || undefined,
altText: m.altText || undefined,
})) : undefined,
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || 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 }
);
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Swarm User Profile Endpoint
*
* GET: Returns a user's profile and posts for swarm requests
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
export interface SwarmUserProfile {
handle: string;
displayName: string;
bio?: string;
avatarUrl?: string;
headerUrl?: string;
website?: string;
followersCount: number;
followingCount: number;
postsCount: number;
createdAt: string;
isBot?: boolean;
nodeDomain: string;
}
export interface SwarmUserPost {
id: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
}
type RouteContext = { params: Promise<{ handle: string }> };
/**
* GET /api/swarm/users/[handle]
*
* Returns a user's profile and recent posts.
* Used by other nodes to display remote user profiles.
*/
export async function GET(request: NextRequest, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Build profile response
const profile: SwarmUserProfile = {
handle: user.handle,
displayName: user.displayName || user.handle,
bio: user.bio || undefined,
avatarUrl: user.avatarUrl || undefined,
headerUrl: user.headerUrl || undefined,
website: user.website || undefined,
followersCount: user.followersCount,
followingCount: user.followingCount,
postsCount: user.postsCount,
createdAt: user.createdAt.toISOString(),
isBot: user.isBot || undefined,
nodeDomain,
};
// Get user's recent posts
const userPosts = await db
.select({
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount,
linkPreviewUrl: posts.linkPreviewUrl,
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
})
.from(posts)
.where(
and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false)
)
)
.orderBy(desc(posts.createdAt))
.limit(limit);
// Fetch media for each post
const swarmPosts: SwarmUserPost[] = [];
for (const post of userPosts) {
const postMedia = await db
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
.from(media)
.where(eq(media.postId, post.id));
swarmPosts.push({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isNsfw: post.isNsfw,
likesCount: post.likesCount,
repostsCount: post.repostsCount,
repliesCount: post.repliesCount,
media: postMedia.length > 0 ? postMedia.map(m => ({
url: m.url,
mimeType: m.mimeType || undefined,
altText: m.altText || undefined,
})) : undefined,
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
});
}
return NextResponse.json({
profile,
posts: swarmPosts,
nodeDomain,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Swarm user profile error:', error);
return NextResponse.json(
{ error: 'Failed to fetch user profile' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// S3 Configuration
const s3 = new S3Client({
region: process.env.STORAGE_REGION || 'us-east-1',
endpoint: process.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
},
forcePathStyle: true, // Needed for many S3-compatible providers (MinIO, etc.)
});
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: buffer,
ContentType: file.type,
ACL: 'public-read', // Depends on bucket policy, but often needed
}));
// Construct Public URL
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else if (process.env.STORAGE_ENDPOINT) {
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
} else {
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
}
return NextResponse.json({ url });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { 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 });
}
}
+422
View File
@@ -0,0 +1,422 @@
import { NextResponse } from 'next/server';
import crypto from 'crypto';
import { db, follows, users, notifications, remoteFollows } from '@/db';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
import { deliverActivity } from '@/lib/activitypub/outbox';
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow } from '@/lib/swarm/interactions';
type RouteContext = { params: Promise<{ handle: string }> };
const parseRemoteHandle = (handle: string) => {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean);
if (parts.length === 2) {
return { handle: parts[0], domain: parts[1] };
}
return null;
};
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
const stripHtml = (html: string | null | undefined): string | null => {
if (!html) return null;
return html.replace(/<[^>]*>/g, '').trim() || null;
};
// Check follow status
export async function GET(request: Request, context: RouteContext) {
try {
const currentUser = await requireAuth();
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
if (currentUser.isSuspended || currentUser.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
if (remote) {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
});
return NextResponse.json({ following: !!existingRemoteFollow, remote: true });
}
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.id === currentUser.id) {
return NextResponse.json({ following: false, self: true });
}
const existingFollow = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
),
});
return NextResponse.json({ following: !!existingFollow });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Follow status error:', error);
return NextResponse.json({ error: 'Failed to get follow status' }, { status: 500 });
}
}
// Follow a user
export async function POST(request: Request, context: RouteContext) {
try {
const currentUser = await requireAuth();
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (currentUser.isSuspended || currentUser.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
if (remote) {
const targetHandle = `${remote.handle}@${remote.domain}`;
// Check if already following
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
});
if (existingRemoteFollow) {
return NextResponse.json({ error: 'Already following' }, { status: 400 });
}
// SWARM-FIRST: Check if this is a Synapsis swarm node
const isSwarm = await isSwarmNode(remote.domain);
if (isSwarm) {
// Use swarm protocol for Synapsis nodes
const activityId = crypto.randomUUID();
const result = await deliverSwarmFollow(remote.domain, {
targetHandle: remote.handle,
follow: {
followerHandle: currentUser.handle,
followerDisplayName: currentUser.displayName || currentUser.handle,
followerAvatarUrl: currentUser.avatarUrl || undefined,
followerBio: currentUser.bio || undefined,
followerNodeDomain: nodeDomain,
interactionId: activityId,
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
// Fall through to ActivityPub below
} else {
// Swarm follow succeeded - store the follow locally
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
activityId,
displayName: null, // Will be fetched later
bio: null,
avatarUrl: null,
});
// Update the user's following count
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
}
}
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
if (!targetInbox) {
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
}
const activityId = crypto.randomUUID();
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
const privateKey = currentUser.privateKeyEncrypted;
if (!privateKey) {
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
}
const result = await deliverActivity(followActivity, targetInbox, privateKey, keyId);
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
}
// Extract avatar URL from remote profile
let avatarUrl: string | null = null;
if (remoteProfile.icon) {
if (typeof remoteProfile.icon === 'string') {
avatarUrl = remoteProfile.icon;
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
avatarUrl = remoteProfile.icon.url;
}
}
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: remoteProfile.id,
inboxUrl: targetInbox,
activityId,
displayName: remoteProfile.name || null,
bio: stripHtml(remoteProfile.summary),
avatarUrl,
});
// Update the user's following count
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
// Cache the remote user's recent posts in the background
const origin = new URL(request.url).origin;
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
.then(result => console.log(`Cached ${result.cached} posts for ${targetHandle}`))
.catch(err => console.error('Error caching remote posts:', err));
return NextResponse.json({ success: true, following: true, remote: true });
}
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
// Find target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Can't follow yourself
if (targetUser.id === currentUser.id) {
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
}
// Check if already following
const existingFollow = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
),
});
if (existingFollow) {
return NextResponse.json({ error: 'Already following' }, { status: 400 });
}
// Create follow
await db.insert(follows).values({
followerId: currentUser.id,
followingId: targetUser.id,
});
if (currentUser.id !== targetUser.id) {
await db.insert(notifications).values({
userId: targetUser.id,
actorId: currentUser.id,
type: 'follow',
});
}
// Update counts
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.where(eq(users.id, targetUser.id));
// TODO: Send ActivityPub Follow activity
return NextResponse.json({ success: true, following: true });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Follow error:', error);
return NextResponse.json({ error: 'Failed to follow' }, { status: 500 });
}
}
// Unfollow a user
export async function DELETE(request: Request, context: RouteContext) {
try {
const currentUser = await requireAuth();
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (remote) {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
});
if (!existingRemoteFollow) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
}
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
if (isSwarmFollow) {
// Use swarm protocol for unfollow
const result = await deliverSwarmUnfollow(remote.domain, {
targetHandle: remote.handle,
unfollow: {
followerHandle: currentUser.handle,
followerNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
// Continue anyway - remove local record
}
// Remove the follow record
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
}
// FALLBACK: Use ActivityPub for non-swarm follows
const originalFollow = createFollowActivity(
currentUser,
existingRemoteFollow.targetActorUrl,
nodeDomain,
existingRemoteFollow.activityId
);
const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID());
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
const privateKey = currentUser.privateKeyEncrypted;
if (!privateKey) {
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
}
const result = await deliverActivity(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId);
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
}
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
return NextResponse.json({ success: true, following: false, remote: true });
}
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
// Find target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Find existing follow
const existingFollow = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
),
});
if (!existingFollow) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
}
// Remove follow
await db.delete(follows).where(eq(follows.id, existingFollow.id));
// Update counts
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.where(eq(users.id, targetUser.id));
// TODO: Send ActivityPub Undo Follow activity
return NextResponse.json({ success: true, following: false });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Unfollow error:', error);
return NextResponse.json({ error: 'Failed to unfollow' }, { status: 500 });
}
}
@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { db, follows, users } from '@/db';
import { eq } from 'drizzle-orm';
type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
// Return empty if no database
if (!db) {
return NextResponse.json({ followers: [], nextCursor: null });
}
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get followers
const userFollowers = await db
.select({
id: follows.id,
follower: users,
})
.from(follows)
.innerJoin(users, eq(follows.followerId, users.id))
.where(eq(follows.followingId, user.id))
.limit(limit);
return NextResponse.json({
followers: userFollowers.map(f => ({
id: f.follower.id,
handle: f.follower.handle,
displayName: f.follower.displayName,
avatarUrl: f.follower.avatarUrl,
bio: f.follower.bio,
isBot: f.follower.isBot,
})),
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
});
} catch (error) {
console.error('Get followers error:', error);
return NextResponse.json({ error: 'Failed to get followers' }, { status: 500 });
}
}
@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { db, follows, users, remoteFollows } from '@/db';
import { eq } from 'drizzle-orm';
type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
// Return empty if no database
if (!db) {
return NextResponse.json({ following: [], nextCursor: null });
}
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get local following
const userFollowing = await db
.select({
id: follows.id,
following: users,
})
.from(follows)
.innerJoin(users, eq(follows.followingId, users.id))
.where(eq(follows.followerId, user.id))
.limit(limit);
const localFollowing = userFollowing.map(f => ({
id: f.following.id,
handle: f.following.handle,
displayName: f.following.displayName,
avatarUrl: f.following.avatarUrl,
bio: f.following.bio,
isRemote: false,
isBot: f.following.isBot,
}));
// Get remote following
const userRemoteFollowing = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
limit,
});
const remoteFollowing = userRemoteFollowing.map(f => ({
id: f.targetActorUrl,
handle: f.targetHandle,
displayName: f.displayName || f.targetHandle.split('@')[0], // Use stored display name or username part
avatarUrl: f.avatarUrl,
bio: f.bio,
isRemote: true,
}));
// Merge and return
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
return NextResponse.json({
following: allFollowing,
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
});
} catch (error) {
console.error('Get following error:', error);
return NextResponse.json({ error: 'Failed to get following' }, { status: 500 });
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* ActivityPub User Inbox Endpoint
*
* Receives incoming activities from remote servers for a specific user.
* POST /users/{handle}/inbox
*/
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
type RouteContext = { params: Promise<{ handle: string }> };
export async function POST(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
// Verify the target user exists
if (!db) {
console.error('[Inbox] Database not available');
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
}
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!targetUser) {
console.error(`[Inbox] User not found: ${cleanHandle}`);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Parse the activity
let activity: IncomingActivity;
try {
activity = await request.json();
} catch (e) {
console.error('[Inbox] Invalid JSON body:', e);
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
console.log(`[Inbox] Received ${activity.type} activity for @${cleanHandle} from ${activity.actor}`);
// Extract headers for signature verification
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Get the request path
const url = new URL(request.url);
const path = url.pathname;
// Process the activity
const result = await processIncomingActivity(activity, headers, path, targetUser);
if (!result.success) {
console.error(`[Inbox] Activity processing failed: ${result.error}`);
return NextResponse.json({ error: result.error }, { status: 400 });
}
// Return 202 Accepted (standard for ActivityPub)
return new NextResponse(null, { status: 202 });
} catch (error) {
console.error('[Inbox] Error processing activity:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ActivityPub requires the inbox to be discoverable
export async function GET(request: Request, context: RouteContext) {
const { handle } = await context.params;
return NextResponse.json(
{
'@context': 'https://www.w3.org/ns/activitystreams',
summary: `Inbox for @${handle}`,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
},
{
headers: {
'Content-Type': 'application/activity+json',
},
}
);
}
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import { db, likes, posts, users } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Don't show likes for bot accounts
if (user.isBot) {
return NextResponse.json({ posts: [] });
}
// Get user's liked posts
const userLikes = await db.query.likes.findMany({
where: eq(likes.userId, user.id),
with: {
post: {
with: {
author: true,
media: true,
bot: true,
},
},
},
orderBy: [desc(likes.createdAt)],
limit,
});
// Filter out any likes where the post was removed and format response
let likedPosts = userLikes
.filter(like => like.post && !like.post.isRemoved)
.map(like => like.post);
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && likedPosts.length > 0) {
const viewer = session.user;
const postIds = likedPosts.map(p => p!.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: likedPostIds.has(p!.id),
isReposted: repostedPostIds.has(p!.id),
})) as any;
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
return NextResponse.json({
posts: likedPosts,
nextCursor: likedPosts.length === limit ? likedPosts[likedPosts.length - 1]?.id : null,
});
} catch (error) {
console.error('Get user likes error:', error);
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 });
}
}
+437
View File
@@ -0,0 +1,437 @@
import { NextResponse } from 'next/server';
import { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type RouteContext = { params: Promise<{ handle: string }> };
const decodeEntities = (value: string) =>
value
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)))
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
const sanitizeText = (value?: string | null) => {
if (!value) return null;
const withoutTags = value.replace(/<[^>]*>/g, ' ');
const decoded = decodeEntities(withoutTags);
return decoded.replace(/\s+/g, ' ').trim() || null;
};
const extractTextAndUrls = (value?: string | null) => {
if (!value) return { text: '', urls: [] as string[] };
let html = value;
// Replace <br> with spaces to avoid words running together.
html = html.replace(/<br\s*\/?>/gi, ' ');
// Replace anchor tags with their hrefs (preferred) or inner text.
html = html.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, text) => {
const cleanedHref = decodeEntities(String(href));
const cleanedText = decodeEntities(String(text)).replace(/<[^>]*>/g, ' ').trim();
return cleanedHref || cleanedText;
});
const withoutTags = html.replace(/<[^>]*>/g, ' ');
const decoded = decodeEntities(withoutTags);
const text = decoded.replace(/\s+/g, ' ').trim();
const urls = Array.from(text.matchAll(/https?:\/\/[^\s]+/gi)).map((match) => match[0]);
return { text, urls };
};
const normalizeUrl = (value: string) => value.replace(/[)\].,!?]+$/, '');
const fetchLinkPreview = async (url: string, origin: string) => {
try {
const previewUrl = new URL('/api/media/preview', origin);
previewUrl.searchParams.set('url', url);
const res = await fetch(previewUrl.toString(), {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(4000),
});
if (!res.ok) return null;
const data = await res.json();
return {
url: data?.url || url,
title: data?.title || null,
description: data?.description || null,
image: data?.image || null,
};
} catch {
return null;
}
};
const stripFirstUrl = (text: string, url: string) => {
const idx = text.indexOf(url);
if (idx === -1) return text;
const before = text.slice(0, idx).trimEnd();
const after = text.slice(idx + url.length).trimStart();
return `${before} ${after}`.trim();
};
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
const normalizeForDedup = (content: string): string => {
return content
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like &lsquo;
.replace(/&#\d+;/g, '') // Remove numeric entities
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
.replace(/[^\w\s]/g, '') // Remove punctuation
.replace(/\s+/g, ' ') // Normalize whitespace
.toLowerCase()
.trim()
.slice(0, 50); // Compare first 50 chars (article title)
};
const parseRemoteHandle = (handle: string) => {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean);
if (parts.length === 2) {
return { handle: parts[0], domain: parts[1] };
}
return null;
};
/**
* Fetch remote user posts via Swarm API (preferred for Synapsis nodes)
*/
const fetchSwarmUserPosts = async (handle: string, domain: string, limit: number) => {
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const url = `${protocol}://${domain}/api/swarm/users/${handle}?limit=${limit}`;
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
if (!data.profile || !data.posts) return null;
return data;
} catch {
return null;
}
};
const fetchOutboxItems = async (outboxUrl: string, limit: number) => {
const res = await fetch(outboxUrl, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (!res.ok) return [];
const data = await res.json();
const first = data?.first;
if (first) {
if (typeof first === 'string') {
const pageRes = await fetch(first, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (!pageRes.ok) return [];
const page = await pageRes.json();
return page?.orderedItems || page?.items || [];
}
return first?.orderedItems || first?.items || [];
}
const items = data?.orderedItems || data?.items || [];
return items.slice(0, limit);
};
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const remote = parseRemoteHandle(handle);
if (!db) {
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
// Try Swarm API first (for Synapsis nodes)
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
if (swarmData?.posts) {
const profile = swarmData.profile;
const authorHandle = `${profile.handle}@${remote.domain}`;
const author = {
id: `swarm:${remote.domain}:${profile.handle}`,
handle: authorHandle,
displayName: profile.displayName || profile.handle,
avatarUrl: profile.avatarUrl,
};
const posts = swarmData.posts.map((post: any) => ({
id: post.id,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
author,
media: post.media || [],
linkPreviewUrl: post.linkPreviewUrl || null,
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
isSwarm: true,
nodeDomain: remote.domain,
originalPostId: post.id,
}));
return NextResponse.json({ posts, nextCursor: null });
}
// Fall back to ActivityPub for non-Synapsis nodes
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile?.outbox) {
return NextResponse.json({ posts: [] });
}
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
const author = {
id: remoteProfile.id || `remote:${authorHandle}`,
handle: authorHandle,
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
bio: sanitizeText(remoteProfile.summary),
};
const posts = [];
const seenIds = new Set<string>();
const seenContentKeys = new Set<string>(); // For content-based dedup
const origin = new URL(request.url).origin;
for (const item of outboxItems) {
const activity = item?.type === 'Create' ? item : null;
const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') {
continue;
}
// Deduplicate by object ID
const postId = object.id || activity.id;
if (seenIds.has(postId)) {
continue;
}
// Content-based dedup: similar content = skip
const contentKey = normalizeForDedup(object.content || '');
if (seenContentKeys.has(contentKey)) {
continue;
}
seenIds.add(postId);
seenContentKeys.add(contentKey);
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
const { text, urls } = extractTextAndUrls(object.content);
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
posts.push({
id: postId,
content: contentText || '',
createdAt: object.published || activity.published || new Date().toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author,
media: attachments
.filter((attachment: any) => attachment?.url)
.map((attachment: any, index: number) => ({
id: `${postId || 'media'}-${index}`,
url: attachment.url,
altText: sanitizeText(attachment.name) || null,
})),
linkPreviewUrl: linkPreview?.url || normalizedUrl,
linkPreviewTitle: linkPreview?.title || null,
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
});
}
return NextResponse.json({ posts, nextCursor: null });
}
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Try Swarm API first (for Synapsis nodes)
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
if (swarmData?.posts) {
const profile = swarmData.profile;
const authorHandle = `${profile.handle}@${remote.domain}`;
const author = {
id: `swarm:${remote.domain}:${profile.handle}`,
handle: authorHandle,
displayName: profile.displayName || profile.handle,
avatarUrl: profile.avatarUrl,
};
const posts = swarmData.posts.map((post: any) => ({
id: post.id,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
author,
media: post.media || [],
linkPreviewUrl: post.linkPreviewUrl || null,
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
isSwarm: true,
nodeDomain: remote.domain,
originalPostId: post.id,
}));
return NextResponse.json({ posts, nextCursor: null });
}
// Fall back to ActivityPub for non-Synapsis nodes
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile?.outbox) {
return NextResponse.json({ posts: [] });
}
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
const author = {
id: remoteProfile.id || `remote:${authorHandle}`,
handle: authorHandle,
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
bio: sanitizeText(remoteProfile.summary),
};
const posts = [];
const seenIds = new Set<string>();
const seenContentKeys = new Set<string>(); // For content-based dedup
const origin = new URL(request.url).origin;
for (const item of outboxItems) {
const activity = item?.type === 'Create' ? item : null;
const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') {
continue;
}
// Deduplicate by object ID
const postId = object.id || activity.id;
if (seenIds.has(postId)) {
continue;
}
// Content-based dedup: similar content = skip
const contentKey = normalizeForDedup(object.content || '');
if (seenContentKeys.has(contentKey)) {
continue;
}
seenIds.add(postId);
seenContentKeys.add(contentKey);
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
const { text, urls } = extractTextAndUrls(object.content);
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
posts.push({
id: postId,
content: contentText || '',
createdAt: object.published || activity.published || new Date().toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author,
media: attachments
.filter((attachment: any) => attachment?.url)
.map((attachment: any, index: number) => ({
id: `${postId || 'media'}-${index}`,
url: attachment.url,
altText: sanitizeText(attachment.name) || null,
})),
linkPreviewUrl: linkPreview?.url || normalizedUrl,
linkPreviewTitle: linkPreview?.title || null,
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
});
}
return NextResponse.json({
posts,
nextCursor: null,
});
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get user's posts
let userPosts: any[] = await db.query.posts.findMany({
where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
with: {
author: true,
media: true,
bot: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && userPosts.length > 0) {
const viewer = session.user;
const postIds = userPosts.map(p => p.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
userPosts = userPosts.map(p => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
}));
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
return NextResponse.json({
posts: userPosts,
nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null,
});
} catch (error) {
console.error('Get user posts error:', error);
return NextResponse.json({ error: 'Failed to get posts' }, { status: 500 });
}
}
+205
View File
@@ -0,0 +1,205 @@
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { userToActor } from '@/lib/activitypub/actor';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type RouteContext = { params: Promise<{ handle: string }> };
const decodeEntities = (value: string) =>
value
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)))
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
const sanitizeText = (value?: string | null) => {
if (!value) return null;
const withoutTags = value.replace(/<[^>]*>/g, ' ');
const decoded = decodeEntities(withoutTags);
return decoded.replace(/\s+/g, ' ').trim() || null;
};
const fetchCollectionCount = async (url?: string | null) => {
if (!url) return 0;
try {
const res = await fetch(url, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (!res.ok) return 0;
const data = await res.json();
if (typeof data?.totalItems === 'number') return data.totalItems;
} catch {
return 0;
}
return 0;
};
/**
* Fetch remote user profile via Swarm API (preferred for Synapsis nodes)
*/
const fetchSwarmProfile = async (handle: string, domain: string) => {
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const url = `${protocol}://${domain}/api/swarm/users/${handle}`;
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
if (!data.profile) return null;
return data;
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
// Return mock user if no database
if (!db) {
return NextResponse.json({
user: {
id: 'demo-user',
handle: cleanHandle,
displayName: cleanHandle,
bio: 'This is a demo profile.',
avatarUrl: null,
headerUrl: null,
followersCount: 0,
followingCount: 0,
postsCount: 0,
createdAt: new Date().toISOString(),
}
});
}
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
if (remoteHandle && remoteDomain) {
// Try Swarm API first (for Synapsis nodes)
const swarmData = await fetchSwarmProfile(remoteHandle, remoteDomain);
if (swarmData?.profile) {
const profile = swarmData.profile;
return NextResponse.json({
user: {
id: `swarm:${remoteDomain}:${profile.handle}`,
handle: `${profile.handle}@${remoteDomain}`,
displayName: profile.displayName,
bio: profile.bio || null,
avatarUrl: profile.avatarUrl || null,
headerUrl: profile.headerUrl || null,
followersCount: profile.followersCount,
followingCount: profile.followingCount,
postsCount: profile.postsCount,
website: profile.website || null,
createdAt: profile.createdAt,
isRemote: true,
isSwarm: true,
nodeDomain: remoteDomain,
isBot: profile.isBot || false,
}
});
}
// Fall back to ActivityPub for non-Synapsis nodes
const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain);
if (remoteProfile) {
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
const iconUrl = typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url;
const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url;
const profileUrl = typeof remoteProfile.url === 'string' ? remoteProfile.url : remoteProfile.id;
const [followersCount, followingCount, postsCount] = await Promise.all([
fetchCollectionCount(remoteProfile.followers),
fetchCollectionCount(remoteProfile.following),
fetchCollectionCount(remoteProfile.outbox),
]);
return NextResponse.json({
user: {
id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`,
handle: `${remoteProfile.preferredUsername || remoteHandle}@${remoteDomain}`,
displayName,
bio: sanitizeText(remoteProfile.summary),
avatarUrl: iconUrl ?? null,
headerUrl: headerUrl ?? null,
followersCount,
followingCount,
postsCount,
website: profileUrl ?? null,
createdAt: new Date().toISOString(),
isRemote: true,
profileUrl: profileUrl ?? null,
}
});
}
}
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if ActivityPub request
const accept = request.headers.get('accept') || '';
if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const actor = userToActor(user, nodeDomain);
return NextResponse.json(actor, {
headers: {
'Content-Type': 'application/activity+json',
},
});
}
// Return user profile (without sensitive data)
// Include bot info if this is a bot account
const userResponse: Record<string, unknown> = {
id: user.id,
handle: user.handle,
displayName: user.displayName,
bio: user.bio,
avatarUrl: user.avatarUrl,
headerUrl: user.headerUrl,
followersCount: user.followersCount,
followingCount: user.followingCount,
postsCount: user.postsCount,
createdAt: user.createdAt,
website: user.website,
movedTo: user.movedTo,
isBot: user.isBot,
};
// If this is a bot, include owner info
if (user.isBot && user.botOwnerId) {
const owner = await db.query.users.findFirst({
where: eq(users.id, user.botOwnerId),
});
if (owner) {
userResponse.botOwner = {
id: owner.id,
handle: owner.handle,
displayName: owner.displayName,
avatarUrl: owner.avatarUrl,
};
}
}
return NextResponse.json({ user: userResponse });
} catch (error) {
console.error('Get user error:', error);
return NextResponse.json({ error: 'Failed to get user' }, { status: 500 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { users } from '@/db';
import { desc, sql } from 'drizzle-orm';
export async function GET(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ users: [] });
}
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
const userList = await db
.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
bio: users.bio,
avatarUrl: users.avatarUrl,
createdAt: users.createdAt,
isBot: users.isBot,
})
.from(users)
.where(sql`${users.isSuspended} IS FALSE`)
.orderBy(desc(users.createdAt))
.limit(limit);
return NextResponse.json({ users: userList });
} catch (error) {
console.error('List users error:', error);
return NextResponse.json({ users: [] });
}
}