feat: user-owned S3-compatible storage with credential verification
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user