Fix federation flows and add versioned updater
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version';
|
||||
import { getHostUpdaterStatus, triggerHostUpdate } from '@/lib/host-updater';
|
||||
|
||||
function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return compareBuildVersions(currentVersion, latestVersion) < 0;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const [latest, updater] = await Promise.all([
|
||||
getLatestPublishedBuild(),
|
||||
getHostUpdaterStatus(),
|
||||
]);
|
||||
|
||||
const current = getCurrentBuildInfo();
|
||||
|
||||
return NextResponse.json({
|
||||
current,
|
||||
latest,
|
||||
updateAvailable: isUpdateAvailable(current.version, latest?.version),
|
||||
updater,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
|
||||
console.error('[Admin Update] Status error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get update status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const result = await triggerHostUpdate();
|
||||
return NextResponse.json(result, { status: 202 });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
|
||||
console.error('[Admin Update] Trigger error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to trigger update' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,56 @@ import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for direct signed action (legacy)
|
||||
const chatReceiveSchema = z.object({
|
||||
const signedChatActionSchema = z.object({
|
||||
action: z.string().min(1),
|
||||
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
handle: z.string().min(3).max(30),
|
||||
ts: z.number(),
|
||||
nonce: z.string().min(1),
|
||||
sig: z.string().min(1),
|
||||
data: z.object({
|
||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
content: z.string().min(1).max(5000),
|
||||
}),
|
||||
});
|
||||
|
||||
// Backward compatibility for older nodes that sent legacy field names.
|
||||
const legacyChatActionSchema = z.object({
|
||||
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
handle: z.string().min(3).max(30),
|
||||
data: z.object({
|
||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
content: z.string().min(1).max(5000),
|
||||
}),
|
||||
signature: z.string(),
|
||||
timestamp: z.number().optional(),
|
||||
signature: z.string().min(1),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
// Schema for federated envelope
|
||||
const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]);
|
||||
|
||||
const federatedEnvelopeSchema = z.object({
|
||||
userAction: chatReceiveSchema,
|
||||
userAction: incomingChatActionSchema,
|
||||
fullSenderHandle: z.string().min(3).max(60),
|
||||
sourceDomain: z.string().min(1),
|
||||
ts: z.number(),
|
||||
});
|
||||
|
||||
function normalizeSignedAction(action: z.infer<typeof incomingChatActionSchema>): SignedAction {
|
||||
if ('sig' in action) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'chat',
|
||||
data: action.data,
|
||||
did: action.did,
|
||||
handle: action.handle,
|
||||
ts: action.timestamp,
|
||||
nonce: `legacy:${action.did}:${action.timestamp}`,
|
||||
sig: action.signature,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/receive
|
||||
* Endpoint for receiving federated chat messages from other nodes.
|
||||
@@ -67,19 +97,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Extract user's signed action and full handle from envelope
|
||||
signedAction = body.userAction;
|
||||
signedAction = normalizeSignedAction(body.userAction);
|
||||
fullSenderHandle = body.fullSenderHandle;
|
||||
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
|
||||
} else {
|
||||
// Legacy format - direct user signed action
|
||||
const actionValidation = chatReceiveSchema.safeParse(body);
|
||||
const actionValidation = incomingChatActionSchema.safeParse(body);
|
||||
if (!actionValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action payload', details: actionValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
signedAction = body;
|
||||
signedAction = normalizeSignedAction(actionValidation.data);
|
||||
}
|
||||
|
||||
const { did, handle, data } = signedAction;
|
||||
|
||||
@@ -79,12 +79,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: user.displayName || user.handle,
|
||||
actorAvatarUrl: user.avatarUrl || undefined,
|
||||
actorNodeDomain: nodeDomain,
|
||||
actorDid: user.did,
|
||||
actorPublicKey: user.publicKey,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
userSignature: signedAction.sig,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -181,12 +178,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: user.displayName || user.handle,
|
||||
actorAvatarUrl: user.avatarUrl || undefined,
|
||||
actorNodeDomain: nodeDomain,
|
||||
actorDid: user.did,
|
||||
actorPublicKey: user.publicKey,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
userSignature: signedAction.sig,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function GET(request: NextRequest) {
|
||||
// Local posts may have apId if they've been federated, so we check nodeId instead
|
||||
let whereCondition = and(
|
||||
isNull(posts.replyToId), // Not a reply
|
||||
isNull(posts.swarmReplyToId), // Not a swarm reply
|
||||
eq(posts.isRemoved, false), // Not removed
|
||||
isNull(users.nodeId) // Local user (not from another swarm node)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, likes } from '@/db';
|
||||
import { eq, desc, and, inArray, lt } from 'drizzle-orm';
|
||||
import { eq, desc, and, inArray, lt, sql } from 'drizzle-orm';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
|
||||
@@ -51,8 +51,8 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = profileData.posts.map((post: any) => ({
|
||||
|
||||
const remotePosts = profileData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
@@ -70,7 +70,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
originalPostId: post.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
return NextResponse.json({ posts: remotePosts, nextCursor: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ posts: [] });
|
||||
@@ -108,13 +108,35 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = profileData.posts.map((post: any) => ({
|
||||
const swarmPostIds = profileData.posts.map((post: any) => `swarm:${remote.domain}:${post.id}`);
|
||||
let localReplyCounts = new Map<string, number>();
|
||||
|
||||
if (swarmPostIds.length > 0) {
|
||||
const counts = await db.select({
|
||||
swarmReplyToId: posts.swarmReplyToId,
|
||||
replyCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(posts)
|
||||
.where(and(
|
||||
inArray(posts.swarmReplyToId, swarmPostIds),
|
||||
eq(posts.isRemoved, false)
|
||||
))
|
||||
.groupBy(posts.swarmReplyToId);
|
||||
|
||||
localReplyCounts = new Map(
|
||||
counts
|
||||
.filter(row => row.swarmReplyToId)
|
||||
.map(row => [row.swarmReplyToId as string, row.replyCount])
|
||||
);
|
||||
}
|
||||
|
||||
const remotePosts = profileData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0),
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
@@ -126,7 +148,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
originalPostId: post.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
return NextResponse.json({ posts: remotePosts, nextCursor: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ posts: [] });
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { execSync } from 'child_process';
|
||||
import { getCurrentBuildInfo } from '@/lib/version';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get the total number of commits
|
||||
const commitCount = execSync('git rev-list --count HEAD', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'] // Ignore stderr
|
||||
}).trim();
|
||||
|
||||
// Get the short hash for reference
|
||||
const commitHash = execSync('git rev-parse --short HEAD', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
|
||||
const fullHash = execSync('git rev-parse HEAD', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
|
||||
// Try to get the GitHub repo URL
|
||||
let githubUrl = null;
|
||||
try {
|
||||
const remoteUrl = execSync('git config --get remote.origin.url', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
|
||||
// Convert git URL to GitHub web URL
|
||||
// Handle both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git)
|
||||
if (remoteUrl.includes('github.com')) {
|
||||
let cleanUrl = remoteUrl
|
||||
.replace('git@github.com:', 'https://github.com/')
|
||||
.replace(/\.git$/, '');
|
||||
|
||||
githubUrl = `${cleanUrl}/commit/${fullHash}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't get the remote URL, that's okay
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
count: parseInt(commitCount, 10),
|
||||
hash: commitHash,
|
||||
fullHash: fullHash,
|
||||
githubUrl: githubUrl
|
||||
});
|
||||
} catch (error) {
|
||||
// If git is not available or not a git repo, return unknown
|
||||
return NextResponse.json({
|
||||
count: null,
|
||||
hash: 'unknown',
|
||||
fullHash: null,
|
||||
githubUrl: null
|
||||
});
|
||||
}
|
||||
return NextResponse.json(getCurrentBuildInfo());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user