feat: user-owned S3-compatible storage with credential verification

This commit is contained in:
Christomatt
2026-02-01 02:51:52 +01:00
parent a8252a1809
commit f55dec9a60
38 changed files with 1845 additions and 902 deletions
@@ -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 });
}
+38 -9
View File
@@ -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 });
}
+17 -13
View File
@@ -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 });
}
}
+13 -9
View File
@@ -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 });
}
}
+10 -10
View File
@@ -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}`);
+34 -3
View File
@@ -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 });
}
+17 -1
View File
@@ -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 });
}