feat: user-owned S3-compatible storage with credential verification
This commit is contained in:
@@ -21,6 +21,7 @@ interface ExportManifest {
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
expiresAt: string; // Export expiration timestamp
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string; // Encrypted with user's password
|
||||
salt: string; // For key derivation
|
||||
@@ -40,7 +41,7 @@ interface ExportPost {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
|
||||
}
|
||||
|
||||
interface ExportFollowing {
|
||||
@@ -209,6 +210,7 @@ export async function POST(req: NextRequest) {
|
||||
filename: `${post.id}_${idx}${getExtension(m.url)}`,
|
||||
url: m.url,
|
||||
altText: m.altText,
|
||||
isIPFS: m.url?.startsWith('ipfs://') || false,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -314,12 +316,15 @@ export async function POST(req: NextRequest) {
|
||||
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Build manifest (without signature first)
|
||||
const exportedAt = new Date();
|
||||
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
const manifestData: Omit<ExportManifest, 'signature'> = {
|
||||
version: '1.0',
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
sourceNode: nodeDomain,
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
publicKey: user.publicKey,
|
||||
privateKeyEncrypted: encrypted,
|
||||
salt,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, posts, media, follows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -19,6 +19,7 @@ interface ImportManifest {
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
expiresAt?: string; // Optional for backward compatibility
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string;
|
||||
salt: string;
|
||||
@@ -38,12 +39,15 @@ interface ImportPost {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
|
||||
}
|
||||
|
||||
interface ImportFollowing {
|
||||
actorUrl: string;
|
||||
handle: string;
|
||||
isRemote?: boolean;
|
||||
inboxUrl?: string;
|
||||
activityId?: string;
|
||||
}
|
||||
|
||||
interface ImportDMConversation {
|
||||
@@ -167,6 +171,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if export has expired
|
||||
if (manifest.expiresAt) {
|
||||
const expiresAt = new Date(manifest.expiresAt);
|
||||
if (expiresAt < new Date()) {
|
||||
return NextResponse.json({
|
||||
error: 'Export has expired. Please create a new export from your old node.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { dms: importDMs = [], bots: importBots = [] } = exportData;
|
||||
|
||||
// Verify signature
|
||||
@@ -263,12 +277,14 @@ export async function POST(req: NextRequest) {
|
||||
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
|
||||
}).returning();
|
||||
|
||||
// Import media references (URLs point to old location for now)
|
||||
// Import media references
|
||||
// For IPFS media (ipfs://hash), the URL works on any node
|
||||
// For legacy S3 media, URL points to old node (may break if old node goes down)
|
||||
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
|
||||
url: mediaItem.url, // IPFS URLs are portable, S3 URLs are not
|
||||
altText: mediaItem.altText,
|
||||
});
|
||||
}
|
||||
@@ -287,6 +303,49 @@ export async function POST(req: NextRequest) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}]);
|
||||
|
||||
// Import following list
|
||||
let importedFollowing = 0;
|
||||
for (const follow of following) {
|
||||
try {
|
||||
if (follow.isRemote) {
|
||||
// Remote follow - add to remoteFollows table
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: newUser.id,
|
||||
targetHandle: follow.handle,
|
||||
targetActorUrl: follow.actorUrl || `https://${follow.handle.split('@')[1]}/users/${follow.handle.split('@')[0]}`,
|
||||
inboxUrl: follow.inboxUrl || `https://${follow.handle.split('@')[1]}/inbox`,
|
||||
activityId: follow.activityId || `migrate-${uuid()}`,
|
||||
});
|
||||
} else {
|
||||
// Local follow - look up user and add to follows table
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, follow.handle.toLowerCase()),
|
||||
});
|
||||
if (targetUser) {
|
||||
await db.insert(follows).values({
|
||||
followerId: newUser.id,
|
||||
followingId: targetUser.id,
|
||||
});
|
||||
// Increment following count on target user
|
||||
await db.update(users)
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
} else {
|
||||
// Local user not found, convert to remote follow
|
||||
console.log(`[Import] Local user @${follow.handle} not found, skipping follow`);
|
||||
}
|
||||
}
|
||||
importedFollowing++;
|
||||
} catch (error) {
|
||||
console.error(`[Import] Failed to restore follow for @${follow.handle}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: importedFollowing })
|
||||
.where(eq(users.id, newUser.id));
|
||||
|
||||
// Import DMs
|
||||
for (const conv of importDMs) {
|
||||
try {
|
||||
@@ -404,7 +463,7 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
stats: {
|
||||
postsImported: importedPosts,
|
||||
followingToRestore: following.length,
|
||||
followingImported: importedFollowing,
|
||||
dmsImported: importDMs.length,
|
||||
botsImported: importBots.length,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { registerUser, createSession } from '@/lib/auth';
|
||||
import { db, nodes, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { testS3Credentials } from '@/lib/storage/s3';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
@@ -11,6 +12,13 @@ const registerSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().optional(),
|
||||
turnstileToken: z.string().nullable().optional(),
|
||||
// S3-compatible storage credentials
|
||||
storageProvider: z.string().min(1),
|
||||
storageEndpoint: z.string().nullable().optional(),
|
||||
storageRegion: z.string().min(1),
|
||||
storageBucket: z.string().min(1),
|
||||
storageAccessKey: z.string().min(10),
|
||||
storageSecretKey: z.string().min(10),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -36,11 +44,37 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test S3 credentials before creating account
|
||||
console.log('[Register] Testing S3 credentials...');
|
||||
const s3Test = await testS3Credentials(
|
||||
data.storageEndpoint || null,
|
||||
data.storageRegion,
|
||||
data.storageBucket,
|
||||
data.storageAccessKey,
|
||||
data.storageSecretKey
|
||||
);
|
||||
|
||||
if (!s3Test.success) {
|
||||
console.error('[Register] S3 credential test failed:', s3Test.error);
|
||||
return NextResponse.json(
|
||||
{ error: `Storage connection failed: ${s3Test.error}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Register] S3 credentials verified successfully');
|
||||
|
||||
const user = await registerUser(
|
||||
data.handle,
|
||||
data.email,
|
||||
data.password,
|
||||
data.displayName
|
||||
data.displayName,
|
||||
data.storageProvider,
|
||||
data.storageEndpoint || null,
|
||||
data.storageRegion,
|
||||
data.storageBucket,
|
||||
data.storageAccessKey,
|
||||
data.storageSecretKey
|
||||
);
|
||||
|
||||
// Check if this is an NSFW node and auto-enable NSFW settings
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
BotHandleTakenError,
|
||||
BotValidationError,
|
||||
} from '@/lib/bots/botManager';
|
||||
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
||||
|
||||
// Schema for creating a bot
|
||||
const createBotSchema = z.object({
|
||||
@@ -42,6 +43,8 @@ const createBotSchema = z.object({
|
||||
timezone: z.string().optional(),
|
||||
}).optional(),
|
||||
autonomousMode: z.boolean().optional(),
|
||||
// Optional: password to generate avatar using owner's S3 storage
|
||||
ownerPassword: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -58,11 +61,33 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const data = createBotSchema.parse(body);
|
||||
|
||||
// Generate bot avatar using owner's S3 storage if password provided and no avatar URL
|
||||
let botAvatarUrl = data.avatarUrl;
|
||||
if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) {
|
||||
try {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
|
||||
|
||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||
botHandle,
|
||||
user.storageEndpoint,
|
||||
user.storageRegion || 'auto',
|
||||
user.storageBucket,
|
||||
user.storageAccessKeyEncrypted,
|
||||
user.storageSecretKeyEncrypted,
|
||||
data.ownerPassword
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Bot API] Failed to generate bot avatar:', err);
|
||||
// Continue without avatar - user can set it later
|
||||
}
|
||||
}
|
||||
|
||||
const bot = await createBot(user.id, {
|
||||
name: data.name,
|
||||
handle: data.handle,
|
||||
bio: data.bio,
|
||||
avatarUrl: data.avatarUrl,
|
||||
avatarUrl: botAvatarUrl,
|
||||
headerUrl: data.headerUrl,
|
||||
personality: data.personality,
|
||||
llmProvider: data.llmProvider,
|
||||
|
||||
@@ -1,26 +1,93 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for direct signed action (legacy)
|
||||
const chatReceiveSchema = z.object({
|
||||
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
handle: z.string().min(3).max(30),
|
||||
data: z.object({
|
||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
content: z.string().min(1).max(5000),
|
||||
}),
|
||||
signature: z.string(),
|
||||
timestamp: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for federated envelope
|
||||
const federatedEnvelopeSchema = z.object({
|
||||
userAction: chatReceiveSchema,
|
||||
fullSenderHandle: z.string().min(3).max(60),
|
||||
sourceDomain: z.string().min(1),
|
||||
ts: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat/receive
|
||||
* Endpoint for receiving federated chat messages from other nodes.
|
||||
* Expects a SignedAction payload from the sender.
|
||||
* Expects either:
|
||||
* 1. A SignedAction payload from the sender (legacy, for backward compatibility)
|
||||
* 2. A federated envelope with node's signature and user's signed action
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const signedAction: SignedAction = await request.json();
|
||||
const body = await request.json();
|
||||
|
||||
// Check if this is a federated envelope (node-signed)
|
||||
const swarmSignature = request.headers.get('X-Swarm-Signature');
|
||||
const sourceDomain = request.headers.get('X-Swarm-Source-Domain');
|
||||
|
||||
let signedAction: SignedAction;
|
||||
let fullSenderHandle: string | null = null;
|
||||
|
||||
if (swarmSignature && sourceDomain && body.userAction) {
|
||||
// Federated envelope format - validate and verify node signature
|
||||
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
|
||||
if (!envelopeValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid envelope payload', details: envelopeValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValidNodeSig = await verifySwarmRequest(
|
||||
{ userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts },
|
||||
swarmSignature,
|
||||
sourceDomain
|
||||
);
|
||||
|
||||
if (!isValidNodeSig) {
|
||||
console.error('[Chat Receive] Invalid node signature from:', sourceDomain);
|
||||
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Extract user's signed action and full handle from envelope
|
||||
signedAction = body.userAction;
|
||||
fullSenderHandle = body.fullSenderHandle;
|
||||
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
|
||||
} else {
|
||||
// Legacy format - direct user signed action
|
||||
const actionValidation = chatReceiveSchema.safeParse(body);
|
||||
if (!actionValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action payload', details: actionValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
signedAction = body;
|
||||
}
|
||||
|
||||
const { did, handle, data } = signedAction;
|
||||
const { recipientDid, content } = data || {};
|
||||
|
||||
if (!did || !handle || !recipientDid || !content) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
|
||||
// Use full handle if provided in envelope, otherwise fall back to signed handle
|
||||
const senderHandle = fullSenderHandle || handle;
|
||||
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
|
||||
|
||||
// 1. Resolve Sender Public Key
|
||||
let senderUser = await db.query.users.findFirst({
|
||||
@@ -28,15 +95,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
let publicKey = senderUser?.publicKey;
|
||||
let senderDisplayName = senderUser?.displayName || handle;
|
||||
let senderDisplayName = senderUser?.displayName || senderHandle;
|
||||
let senderAvatarUrl = senderUser?.avatarUrl;
|
||||
let senderNodeDomain: string | null = null;
|
||||
|
||||
if (!senderUser) {
|
||||
// Unknown user - likely remote. We need to fetch their profile to get the public key.
|
||||
// Derive domain from handle if possible
|
||||
if (handle.includes('@')) {
|
||||
const parts = handle.split('@');
|
||||
// Derive domain from full sender handle if possible
|
||||
if (senderHandle.includes('@')) {
|
||||
const parts = senderHandle.split('@');
|
||||
senderNodeDomain = parts[parts.length - 1];
|
||||
} else {
|
||||
// Try to get from header first
|
||||
@@ -55,26 +122,44 @@ export async function POST(request: NextRequest) {
|
||||
if (senderNodeDomain) {
|
||||
try {
|
||||
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
// Fetch profile from remote node
|
||||
// Assuming /api/users/:handle convention
|
||||
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
|
||||
|
||||
if (res.ok) {
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
if (remoteProfile && remoteProfile.publicKey) {
|
||||
if (remoteProfile.did !== did) {
|
||||
console.error('DID mismatch for remote user');
|
||||
} else {
|
||||
publicKey = remoteProfile.publicKey;
|
||||
senderDisplayName = remoteProfile.displayName || handle;
|
||||
senderAvatarUrl = remoteProfile.avatarUrl;
|
||||
// Fetch public key with TOFU validation
|
||||
const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
|
||||
did,
|
||||
senderHandle,
|
||||
senderNodeDomain,
|
||||
async () => {
|
||||
// Fetch profile from remote node
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
if (!res.ok) return null;
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
if (remoteProfile?.did !== did) {
|
||||
console.error('[Chat Receive] DID mismatch for remote user');
|
||||
return null;
|
||||
}
|
||||
return remoteProfile?.publicKey || null;
|
||||
}
|
||||
);
|
||||
|
||||
if (cachedOrFreshKey) {
|
||||
publicKey = cachedOrFreshKey;
|
||||
console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`);
|
||||
|
||||
// Also fetch display name/avatar if not from cache (or if we need fresh data)
|
||||
if (!fromCache || keyChanged) {
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
if (res.ok) {
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
senderDisplayName = remoteProfile?.displayName || senderHandle;
|
||||
senderAvatarUrl = remoteProfile?.avatarUrl;
|
||||
|
||||
// CACHE: Upsert the remote user into our local database
|
||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||
await upsertRemoteUser({
|
||||
handle: handle, // already full handle if remote
|
||||
handle: senderHandle, // use full handle (user@domain)
|
||||
displayName: senderDisplayName,
|
||||
avatarUrl: senderAvatarUrl || null,
|
||||
did: did || '',
|
||||
@@ -83,7 +168,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch remote profile:', res.status);
|
||||
console.error('Failed to fetch remote profile: no key returned');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Remote profile fetch failed:', e);
|
||||
@@ -112,20 +197,20 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 4. Find or Create Conversation
|
||||
// For the RECIPIENT, the conversation is with the SENDER
|
||||
// Use full handle
|
||||
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle);
|
||||
// Use full handle from envelope if available, otherwise construct from handle + domain
|
||||
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
|
||||
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipientUser.id),
|
||||
eq(chatConversations.participant2Handle, fullSenderHandle)
|
||||
eq(chatConversations.participant2Handle, computedFullSenderHandle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipientUser.id,
|
||||
participant2Handle: fullSenderHandle,
|
||||
participant2Handle: computedFullSenderHandle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: content.slice(0, 50)
|
||||
}).returning();
|
||||
@@ -144,7 +229,7 @@ export async function POST(request: NextRequest) {
|
||||
// 5. Store Message
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: fullSenderHandle,
|
||||
senderHandle: computedFullSenderHandle,
|
||||
senderDisplayName: senderDisplayName,
|
||||
senderAvatarUrl: senderAvatarUrl,
|
||||
senderNodeDomain: senderNodeDomain,
|
||||
@@ -156,7 +241,7 @@ export async function POST(request: NextRequest) {
|
||||
// 6. Update Registry (to ensure we can reply efficiently)
|
||||
if (senderNodeDomain) {
|
||||
await db.insert(handleRegistry).values({
|
||||
handle: fullSenderHandle, // user@domain
|
||||
handle: computedFullSenderHandle, // user@domain
|
||||
did: did,
|
||||
nodeDomain: senderNodeDomain
|
||||
}).onConflictDoUpdate({
|
||||
@@ -172,6 +257,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Federated Receive Failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { chatConversations, chatMessages, users, handleRegistry, follows } from
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { createSignedPayload } from '@/lib/swarm/signature';
|
||||
|
||||
const handleRegex = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
|
||||
const chatSendSchema = z.object({
|
||||
recipientDid: z.string(),
|
||||
recipientHandle: z.string(),
|
||||
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
|
||||
recipientHandle: z.string().min(3).max(30).regex(handleRegex, 'Handle must be 3-20 characters, alphanumeric and underscores only'),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
@@ -33,7 +36,14 @@ export async function POST(request: NextRequest) {
|
||||
where: eq(users.did, recipientDid)
|
||||
});
|
||||
|
||||
if (recipientUser) {
|
||||
// Check if recipient is a local user (not remote/swarm cached)
|
||||
// Remote users have handles with @domain or IDs starting with "swarm:"
|
||||
const isRemoteUser = recipientUser && (
|
||||
recipientUser.handle.includes('@') ||
|
||||
recipientUser.id.startsWith('swarm:')
|
||||
);
|
||||
|
||||
if (recipientUser && !isRemoteUser) {
|
||||
// Reject if recipient is a bot
|
||||
if (recipientUser.isBot) {
|
||||
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
|
||||
@@ -165,19 +175,35 @@ export async function POST(request: NextRequest) {
|
||||
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
|
||||
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
|
||||
|
||||
// Construct full sender handle for federation
|
||||
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
|
||||
|
||||
console.log(`[Remote Send] Debug:`, {
|
||||
targetDomain,
|
||||
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
|
||||
sourceDomainEnv: sourceDomain,
|
||||
fullSenderHandle,
|
||||
});
|
||||
|
||||
// Create a federated envelope with node's signature
|
||||
// This wraps the user's signed action with the full handle
|
||||
const federatedPayload = {
|
||||
userAction: signedAction,
|
||||
fullSenderHandle,
|
||||
sourceDomain,
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
const { payload, signature } = await createSignedPayload(federatedPayload);
|
||||
|
||||
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Swarm-Source-Domain': sourceDomain
|
||||
'X-Swarm-Source-Domain': sourceDomain,
|
||||
'X-Swarm-Signature': signature,
|
||||
},
|
||||
body: JSON.stringify(signedAction) // Forward the user's signed intent
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server';
|
||||
import { db, handleRegistry } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
|
||||
import { z } from 'zod';
|
||||
|
||||
const handleParamSchema = z.string().min(3).max(40).regex(/^[a-zA-Z0-9_]+(@[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,})?$/, 'Invalid handle format');
|
||||
|
||||
const parseHandleWithDomain = (handle: string) => {
|
||||
const clean = normalizeHandle(handle);
|
||||
@@ -19,12 +22,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handleParam = searchParams.get('handle');
|
||||
const handleParamRaw = searchParams.get('handle');
|
||||
|
||||
if (!handleParam) {
|
||||
if (!handleParamRaw) {
|
||||
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate handle format
|
||||
const handleValidation = handleParamSchema.safeParse(handleParamRaw);
|
||||
if (!handleValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid handle format', details: handleValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const handleParam = handleValidation.data;
|
||||
|
||||
const parsed = parseHandleWithDomain(handleParam);
|
||||
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
|
||||
const localEntry = await db.query.handleRegistry.findFirst({
|
||||
@@ -63,6 +76,12 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('Handle resolve error:', error);
|
||||
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { uploadToUserStorage } from '@/lib/storage/s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
|
||||
@@ -16,6 +16,7 @@ export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const altText = (formData.get('alt') as string | null) || null;
|
||||
const password = formData.get('password') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
@@ -39,47 +40,43 @@ export async function POST(req: NextRequest) {
|
||||
}, { 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 });
|
||||
// Check if user has S3 storage configured
|
||||
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
|
||||
return NextResponse.json({
|
||||
error: 'Storage not configured. Please set up S3-compatible storage in your settings.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Store media record
|
||||
// Require password to decrypt storage credentials
|
||||
if (!password) {
|
||||
return NextResponse.json({
|
||||
error: 'Password required to upload media. Your storage credentials are encrypted and need your password to decrypt.'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// Upload to user's own S3-compatible storage
|
||||
const uploadResult = await uploadToUserStorage(
|
||||
buffer,
|
||||
filename,
|
||||
file.type,
|
||||
user.storageProvider as any,
|
||||
user.storageEndpoint,
|
||||
user.storageRegion || 'us-east-1',
|
||||
user.storageBucket || '',
|
||||
user.storageAccessKeyEncrypted,
|
||||
user.storageSecretKeyEncrypted,
|
||||
password
|
||||
);
|
||||
|
||||
// Store media record with S3 URL
|
||||
if (db) {
|
||||
const [mediaRecord] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url,
|
||||
url: uploadResult.url,
|
||||
altText,
|
||||
mimeType: file.type,
|
||||
width: 0, // TODO: Get actual dimensions
|
||||
@@ -89,19 +86,24 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
media: mediaRecord,
|
||||
url,
|
||||
url: uploadResult.url,
|
||||
key: uploadResult.key,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
url: uploadResult.url,
|
||||
key: uploadResult.key,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Storage')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@ import { NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// UUID or swarm post ID format (swarm:domain:uuid)
|
||||
const postIdSchema = z.union([
|
||||
z.string().uuid(),
|
||||
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
@@ -43,9 +50,10 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract postId from the signed action data
|
||||
// Extract and validate postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -118,9 +126,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
postId,
|
||||
});
|
||||
|
||||
// Update post's like count
|
||||
// Update post's like count (atomic increment)
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.set({ likesCount: sql`${posts.likesCount} + 1` })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
@@ -187,7 +195,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering like:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Like] Error delivering like to swarm:', err);
|
||||
console.error('[Like] Context:', { postId: originalPostId, userId: user.id, targetDomain });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -197,6 +207,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, liked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'User not found' ||
|
||||
@@ -222,9 +235,10 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract postId from the signed action data
|
||||
// Extract and validate postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -289,9 +303,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Remove like
|
||||
await db.delete(likes).where(eq(likes.id, existingLike.id));
|
||||
|
||||
// Update post's like count
|
||||
// Update post's like count (atomic decrement, clamped to 0)
|
||||
await db.update(posts)
|
||||
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||
.set({ likesCount: sql`GREATEST(0, ${posts.likesCount} - 1)` })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// SWARM-FIRST: Deliver unlike to swarm node
|
||||
@@ -320,7 +334,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering unlike:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Like] Error delivering unlike to swarm:', err);
|
||||
console.error('[Like] Context:', { postId: originalPostId, userId: user.id, targetDomain });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -328,6 +344,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, liked: false });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'User not found' ||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// UUID or swarm post ID format (swarm:domain:uuid)
|
||||
const postIdSchema = z.union([
|
||||
z.string().uuid(),
|
||||
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
@@ -38,7 +45,8 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: rawId } = await context.params;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -116,12 +124,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
// Update original post's repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: originalPost.repostsCount + 1 })
|
||||
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.set({ postsCount: sql`${users.postsCount} + 1` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (originalPost.userId !== user.id) {
|
||||
@@ -196,6 +204,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, repost, reposted: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
@@ -208,7 +219,8 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: rawId } = await context.params;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -272,17 +284,20 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Update original post's repost count
|
||||
if (originalPost) {
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: Math.max(0, originalPost.repostsCount - 1) })
|
||||
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
||||
.where(eq(posts.id, postId));
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: Math.max(0, user.postsCount - 1) })
|
||||
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({ success: true, reposted: false });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remotePosts } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { eq, desc, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for local post ID (UUID)
|
||||
const localPostIdSchema = z.string().uuid('Invalid post ID format');
|
||||
|
||||
// Schema for swarm post ID (swarm:domain:uuid)
|
||||
const swarmPostIdSchema = z.string().regex(
|
||||
/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
'Invalid swarm post ID format'
|
||||
);
|
||||
|
||||
// Combined schema that accepts either format
|
||||
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -424,15 +437,10 @@ export async function DELETE(
|
||||
// 3. Delete the post (cascades to media, likes, notifications)
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
// 4. 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));
|
||||
}
|
||||
// 4. Decrement the post author's postsCount (atomic decrement, clamped to 0)
|
||||
await db.update(users)
|
||||
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
|
||||
.where(eq(users.id, post.userId));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
+17
-16
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt } from 'drizzle-orm';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -19,7 +19,7 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
||||
|
||||
const createPostSchema = z.object({
|
||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||
replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
|
||||
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
|
||||
swarmReplyTo: z.object({
|
||||
postId: z.string(),
|
||||
nodeDomain: z.string(),
|
||||
@@ -105,21 +105,16 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
// Update user's post count (atomic increment to prevent race conditions)
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.set({ postsCount: sql`${users.postsCount} + 1` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// If this is a reply, update the parent's reply count
|
||||
// If this is a reply, update the parent's reply count (atomic increment)
|
||||
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));
|
||||
}
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
|
||||
.where(eq(posts.id, data.replyToId));
|
||||
}
|
||||
|
||||
// DEPRECATED: Push-based federation disabled
|
||||
@@ -213,7 +208,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Local] Error creating mention notifications:', err);
|
||||
// Log error with context but don't fail the request - mention notifications are best-effort
|
||||
console.error('[Posts] Error creating mention notifications:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: data.content?.slice(0, 100) });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -237,7 +234,9 @@ export async function POST(request: Request) {
|
||||
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering mentions:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Posts] Error delivering swarm mentions:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -264,7 +263,9 @@ export async function POST(request: Request) {
|
||||
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering post:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Posts] Error delivering post to swarm followers:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -8,6 +8,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for conversation ID parameter
|
||||
const conversationIdSchema = z.string().uuid('Invalid conversation ID format');
|
||||
|
||||
// Schema for delete query parameter
|
||||
const deleteQuerySchema = z.object({
|
||||
deleteFor: z.enum(['self', 'both']).optional(),
|
||||
});
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
@@ -24,8 +33,23 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Validate conversation ID
|
||||
const idResult = conversationIdSchema.safeParse(id);
|
||||
if (!idResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid conversation ID', details: idResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const deleteFor = searchParams.get('deleteFor'); // 'self' or 'both'
|
||||
const deleteForRaw = searchParams.get('deleteFor'); // 'self' or 'both'
|
||||
|
||||
// Validate deleteFor parameter
|
||||
const deleteForResult = deleteQuerySchema.safeParse({ deleteFor: deleteForRaw || undefined });
|
||||
if (!deleteForResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid deleteFor parameter', details: deleteForResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { deleteFor } = deleteForResult.data;
|
||||
|
||||
// Verify the conversation belongs to this user
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
@@ -118,6 +142,9 @@ export async function DELETE(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Delete conversation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for query parameters
|
||||
const messagesQuerySchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
cursor: z.string().datetime().optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
// Schema for PATCH request body
|
||||
const markReadSchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
});
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -23,14 +36,20 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const conversationId = searchParams.get('conversationId');
|
||||
const cursor = searchParams.get('cursor'); // For pagination
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
|
||||
|
||||
// Validate query parameters
|
||||
const queryResult = messagesQuerySchema.safeParse({
|
||||
conversationId: searchParams.get('conversationId'),
|
||||
cursor: searchParams.get('cursor') || undefined,
|
||||
limit: parseInt(searchParams.get('limit') || '50'),
|
||||
});
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ error: 'conversationId required' }, { status: 400 });
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid query parameters', details: queryResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { conversationId, cursor, limit } = queryResult.data;
|
||||
|
||||
// Verify user has access to this conversation
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
@@ -114,6 +133,9 @@ export async function GET(request: NextRequest) {
|
||||
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Get messages error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
|
||||
}
|
||||
@@ -130,11 +152,15 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { conversationId } = await request.json();
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ error: 'conversationId required' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const bodyResult = markReadSchema.safeParse(body);
|
||||
if (!bodyResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body', details: bodyResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { conversationId } = bodyResult.data;
|
||||
|
||||
// Verify user has access to this conversation
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
@@ -160,6 +186,9 @@ export async function PATCH(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Mark as read error:', error);
|
||||
return NextResponse.json({ error: 'Failed to mark as read' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -11,22 +11,22 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmFollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
targetHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
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(),
|
||||
followerHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
followerDisplayName: z.string().min(1).max(50),
|
||||
followerAvatarUrl: z.string().url().optional(),
|
||||
followerBio: z.string().max(500).optional(),
|
||||
followerNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
|
||||
interactionId: z.string().uuid(),
|
||||
timestamp: z.string().datetime(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
signature: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -100,7 +100,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
@@ -115,7 +115,9 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create notification:`, notifError);
|
||||
// Log error with context but don't fail the request - notification creation is best-effort
|
||||
console.error('[Swarm Follow] Failed to create notification:', notifError);
|
||||
console.error('[Swarm Follow] Context:', { targetHandle: data.targetHandle, userId: targetUser.id, actor: data.follow.followerHandle });
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot being followed
|
||||
@@ -130,7 +132,9 @@ export async function POST(request: NextRequest) {
|
||||
type: 'follow',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
// Log error with context but don't fail the request - bot owner notification is best-effort
|
||||
console.error('[Swarm Follow] Failed to notify bot owner:', err);
|
||||
console.error('[Swarm Follow] Context:', { targetHandle: data.targetHandle, botOwnerId: targetUser.botOwnerId, actor: data.follow.followerHandle });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
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(),
|
||||
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
actorDisplayName: z.string().min(1).max(50),
|
||||
actorAvatarUrl: z.string().url().optional(),
|
||||
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
|
||||
interactionId: z.string().uuid(),
|
||||
timestamp: z.string().datetime(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
signature: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -106,7 +106,9 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create like notification:`, notifError);
|
||||
// Log error with context but don't fail the request - notification creation is best-effort
|
||||
console.error('[Swarm Like] Failed to create notification:', notifError);
|
||||
console.error('[Swarm Like] Context:', { postId: data.postId, userId: post.userId, actor: data.like.actorHandle });
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
@@ -124,7 +126,9 @@ export async function POST(request: NextRequest) {
|
||||
type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
// Log error with context but don't fail the request - bot owner notification is best-effort
|
||||
console.error('[Swarm Like] Failed to notify bot owner:', err);
|
||||
console.error('[Swarm Like] Context:', { postId: data.postId, botOwnerId: author.botOwnerId, actor: data.like.actorHandle });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,22 +8,22 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
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(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
actorDisplayName: z.string().min(1).max(50),
|
||||
actorAvatarUrl: z.string().url().optional(),
|
||||
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
|
||||
repostId: z.string().uuid(),
|
||||
interactionId: z.string().uuid(),
|
||||
timestamp: z.string().datetime(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
signature: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Increment repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: post.repostsCount + 1 })
|
||||
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Decrement repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: Math.max(0, post.repostsCount - 1) })
|
||||
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, remoteLikes } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Schema for post ID parameter
|
||||
const postIdSchema = z.string().uuid('Invalid post ID format');
|
||||
|
||||
// Schema for query parameters
|
||||
const likesQuerySchema = z.object({
|
||||
checkHandle: z.string().min(3).max(30).optional(),
|
||||
checkDomain: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/swarm/posts/[id]/likes
|
||||
*
|
||||
@@ -24,10 +34,28 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id: postId } = await context.params;
|
||||
const { id: rawId } = await context.params;
|
||||
|
||||
// Validate post ID
|
||||
const idResult = postIdSchema.safeParse(rawId);
|
||||
if (!idResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: idResult.error.issues }, { status: 400 });
|
||||
}
|
||||
const postId = idResult.data;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const checkHandle = searchParams.get('checkHandle');
|
||||
const checkDomain = searchParams.get('checkDomain');
|
||||
|
||||
// Validate query parameters
|
||||
const queryResult = likesQuerySchema.safeParse({
|
||||
checkHandle: searchParams.get('checkHandle') || undefined,
|
||||
checkDomain: searchParams.get('checkDomain') || undefined,
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid query parameters', details: queryResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { checkHandle, checkDomain } = queryResult.data;
|
||||
|
||||
// Find the post
|
||||
const post = await db.query.posts.findFirst({
|
||||
@@ -94,6 +122,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
likesCount: post.likesCount,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Post likes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
|
||||
/**
|
||||
* GET /api/swarm/posts/[id]
|
||||
*
|
||||
@@ -21,7 +24,14 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id: postId } = await context.params;
|
||||
const { id: postIdRaw } = await context.params;
|
||||
|
||||
// Validate postId is a valid UUID
|
||||
const postIdValidation = uuidSchema.safeParse(postIdRaw);
|
||||
if (!postIdValidation.success) {
|
||||
return NextResponse.json({ error: 'Invalid post ID format' }, { status: 400 });
|
||||
}
|
||||
const postId = postIdValidation.data;
|
||||
|
||||
// Find the post
|
||||
const post = await db.query.posts.findFirst({
|
||||
@@ -97,6 +107,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('[Swarm] Post detail error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get post' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
@@ -152,9 +152,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
// Update the user's following count (atomic increment)
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.set({ followingCount: sql`${users.followingCount} + 1` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
@@ -231,13 +231,13 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update counts
|
||||
// Update counts (atomic increments)
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.set({ followingCount: sql`${users.followingCount} + 1` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: true });
|
||||
@@ -295,9 +295,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
// Update the user's following count (atomic decrement, clamped to 0)
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
@@ -335,13 +335,13 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Remove follow
|
||||
await db.delete(follows).where(eq(follows.id, existingFollow.id));
|
||||
|
||||
// Update counts
|
||||
// Update counts (atomic decrements, clamped to 0)
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: false });
|
||||
|
||||
Reference in New Issue
Block a user