feat: Enable deletion of swarm-originated posts and replies by propagating requests to remote nodes and improve swarm ID parsing.

This commit is contained in:
Christomatt
2026-01-26 14:56:05 +01:00
parent 74ec163625
commit 38ddede873
4 changed files with 179 additions and 79 deletions
+8 -5
View File
@@ -11,8 +11,9 @@ type RouteContext = { params: Promise<{ id: string }> };
*/ */
function extractSwarmDomain(apId: string | null): string | null { function extractSwarmDomain(apId: string | null): string | null {
if (!apId?.startsWith('swarm:')) return null; if (!apId?.startsWith('swarm:')) return null;
const parts = apId.split(':'); const lastColonIndex = apId.lastIndexOf(':');
return parts.length >= 2 ? parts[1] : null; if (lastColonIndex <= 6) return null;
return apId.substring(6, lastColonIndex);
} }
/** /**
@@ -26,8 +27,10 @@ function isSwarmPost(apId: string | null): boolean {
* Extract the original post ID from a swarm apId * Extract the original post ID from a swarm apId
*/ */
function extractSwarmPostId(apId: string): string | null { function extractSwarmPostId(apId: string): string | null {
const parts = apId.split(':'); if (!apId) return null;
return parts.length >= 3 ? parts[2] : null; const lastColonIndex = apId.lastIndexOf(':');
if (lastColonIndex === -1) return null;
return apId.substring(lastColonIndex + 1);
} }
// Like a post // Like a post
@@ -45,7 +48,7 @@ export async function POST(request: Request, context: RouteContext) {
// Handle swarm posts (format: swarm:domain:uuid) // Handle swarm posts (format: swarm:domain:uuid)
if (postId.startsWith('swarm:')) { if (postId.startsWith('swarm:')) {
const targetDomain = extractSwarmDomain(postId); const targetDomain = extractSwarmDomain(postId);
const originalPostId = postId.split(':')[2]; const originalPostId = extractSwarmPostId(postId);
if (!targetDomain || !originalPostId) { if (!targetDomain || !originalPostId) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
+9 -6
View File
@@ -11,8 +11,9 @@ type RouteContext = { params: Promise<{ id: string }> };
*/ */
function extractSwarmDomain(apId: string | null): string | null { function extractSwarmDomain(apId: string | null): string | null {
if (!apId?.startsWith('swarm:')) return null; if (!apId?.startsWith('swarm:')) return null;
const parts = apId.split(':'); const lastColonIndex = apId.lastIndexOf(':');
return parts.length >= 2 ? parts[1] : null; if (lastColonIndex <= 6) return null;
return apId.substring(6, lastColonIndex);
} }
/** /**
@@ -26,8 +27,10 @@ function isSwarmPost(apId: string | null): boolean {
* Extract the original post ID from a swarm apId * Extract the original post ID from a swarm apId
*/ */
function extractSwarmPostId(apId: string): string | null { function extractSwarmPostId(apId: string): string | null {
const parts = apId.split(':'); if (!apId) return null;
return parts.length >= 3 ? parts[2] : null; const lastColonIndex = apId.lastIndexOf(':');
if (lastColonIndex === -1) return null;
return apId.substring(lastColonIndex + 1);
} }
// Repost a post // Repost a post
@@ -45,7 +48,7 @@ export async function POST(request: Request, context: RouteContext) {
// Handle swarm posts (format: swarm:domain:uuid) // Handle swarm posts (format: swarm:domain:uuid)
if (postId.startsWith('swarm:')) { if (postId.startsWith('swarm:')) {
const targetDomain = extractSwarmDomain(postId); const targetDomain = extractSwarmDomain(postId);
const originalPostId = postId.split(':')[2]; const originalPostId = extractSwarmPostId(postId);
if (!targetDomain || !originalPostId) { if (!targetDomain || !originalPostId) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
@@ -241,7 +244,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Handle swarm posts (format: swarm:domain:uuid) // Handle swarm posts (format: swarm:domain:uuid)
if (postId.startsWith('swarm:')) { if (postId.startsWith('swarm:')) {
const targetDomain = extractSwarmDomain(postId); const targetDomain = extractSwarmDomain(postId);
const originalPostId = postId.split(':')[2]; const originalPostId = extractSwarmPostId(postId);
if (!targetDomain || !originalPostId) { if (!targetDomain || !originalPostId) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
+122 -29
View File
@@ -19,10 +19,10 @@ export async function GET(
// Handle swarm post IDs (format: swarm:domain:uuid) // Handle swarm post IDs (format: swarm:domain:uuid)
if (id.startsWith('swarm:')) { if (id.startsWith('swarm:')) {
const parts = id.split(':'); const lastColonIndex = id.lastIndexOf(':');
if (parts.length >= 3) { if (lastColonIndex > 6) {
const originDomain = parts[1]; const originDomain = id.substring(6, lastColonIndex);
const originalPostId = parts[2]; const originalPostId = id.substring(lastColonIndex + 1);
// Fetch from origin node in real-time // Fetch from origin node in real-time
try { try {
@@ -257,6 +257,100 @@ export async function DELETE(
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Handle swarm post IDs (format: swarm:domain:uuid)
if (id.startsWith('swarm:')) {
const lastColonIndex = id.lastIndexOf(':');
if (lastColonIndex > 6) {
const originDomain = id.substring(6, lastColonIndex);
const originalPostId = id.substring(lastColonIndex + 1);
// We need to fetch the post from the remote node to check if the current user is the author
// The remote node should have the post with proper attribution
try {
const protocol = originDomain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${originDomain}/api/swarm/posts/${originalPostId}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const data = await res.json();
const remotePost = data.post;
// Check authorship
// Format: handle or handle@domain
// If the user authored it, the remote post author handle should match current user handle
// AND the remote post author node domain should be THIS node
// The remote node returns author as:
// { handle: "user", displayName: "...", nodeDomain: "our-domain" }
// OR if it's a "local" user on that node (which shouldn't correspond to us unless we possess that account)
// In the swarm reply scenario, the remote node stores our user as a "remote user"
// Its logic: handle = "user@our-domain"
// So we check if remotePost.author.handle starts with user.handle
// AND (remotePost.author.handle ends with @nodeDomain OR remotePost.nodeDomain == nodeDomain?)
let isAuthor = false;
if (remotePost.author.handle === user.handle ||
remotePost.author.handle === `${user.handle}@${nodeDomain}`) {
isAuthor = true;
}
if (!isAuthor) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// It is our post (or reply). We can delete it.
// We need the original ID that WE sent when creating it.
// Ideally, the remote node preserves the apId we sent, which contains our original ID.
// But the remote node endpoint returns 'apId', let's use that if available.
let replyIdToDelete = originalPostId; // Fallback to their ID if we can't find ours (unlikely to work for replies endpoint)
// If we are deleting a reply we sent
// The remote node has it stored.
// We need to send DELETE /api/swarm/replies with { replyId: <OUR_ID> }
// The remote node checks `swarm:ourDomain:<OUR_ID>`
// We need to extract <OUR_ID> from the remote post's apId
// remotePost.apId should be `swarm:ourDomain:ourId`
if (remotePost.apId && remotePost.apId.startsWith(`swarm:${nodeDomain}:`)) {
const parts = remotePost.apId.split(':');
if (parts.length >= 3) {
replyIdToDelete = parts[2];
}
}
// Propagate deletion
const deleteRes = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
replyId: replyIdToDelete,
nodeDomain,
authorHandle: user.handle,
}),
signal: AbortSignal.timeout(5000),
});
if (deleteRes.ok) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: 'Failed to delete on remote node' }, { status: deleteRes.status });
}
} else {
return NextResponse.json({ error: 'Remote post not found for verification' }, { status: 404 });
}
} catch (err) {
console.error('[Swarm] Error deleting remote post:', err);
return NextResponse.json({ error: 'Failed to communicate with remote node' }, { status: 502 });
}
}
}
const post = await db.query.posts.findFirst({ const post = await db.query.posts.findFirst({
where: eq(posts.id, id), where: eq(posts.id, id),
with: { with: {
@@ -302,34 +396,33 @@ export async function DELETE(
// 2. If this is a reply to a swarm post, notify the origin node to delete it // 2. If this is a reply to a swarm post, notify the origin node to delete it
if (post.swarmReplyToId) { if (post.swarmReplyToId) {
const parts = post.swarmReplyToId.split(':'); // Correctly parse swarm:domain:postId where domain might contain port
if (parts.length >= 3) { const lastColonIndex = post.swarmReplyToId.lastIndexOf(':');
const originDomain = parts[1]; if (lastColonIndex > 6) { // 'swarm:'.length = 6
const originDomain = post.swarmReplyToId.substring(6, lastColonIndex);
// Fire and forget - don't block on this // Propagate deletion to origin node
(async () => { try {
try { const protocol = originDomain.includes('localhost') ? 'http' : 'https';
const protocol = originDomain.includes('localhost') ? 'http' : 'https'; const res = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, {
const res = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, { method: 'DELETE',
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
body: JSON.stringify({ replyId: post.id,
replyId: post.id, nodeDomain,
nodeDomain, authorHandle: user.handle,
authorHandle: user.handle, }),
}), signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000), });
});
if (res.ok) { if (res.ok) {
console.log(`[Swarm] Deletion propagated to ${originDomain}`); console.log(`[Swarm] Deletion propagated to ${originDomain}`);
} else { } else {
console.error(`[Swarm] Failed to propagate deletion: ${res.status}`); console.error(`[Swarm] Failed to propagate deletion: ${res.status}`);
}
} catch (err) {
console.error('[Swarm] Error propagating deletion:', err);
} }
})(); } catch (err) {
console.error('[Swarm] Error propagating deletion:', err);
}
} }
} }
+1
View File
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
return NextResponse.json({ return NextResponse.json({
post: { post: {
id: post.id, id: post.id,
apId: post.apId, // Expose apId for swarm coordination (e.g. deletion recovery)
content: post.content, content: post.content,
createdAt: post.createdAt.toISOString(), createdAt: post.createdAt.toISOString(),
likesCount: post.likesCount, likesCount: post.likesCount,