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