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
+30 -11
View File
@@ -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' ||
+22 -7
View File
@@ -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 });
}
+18 -10
View File
@@ -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) {