feat: add home-side federation identity delete trigger endpoint
This commit is contained in:
@@ -3,7 +3,7 @@ import { eq, or, and, inArray } from 'drizzle-orm';
|
|||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity } from '@backspace/shared';
|
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity, FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationIdentityDeleteResult } from '@backspace/shared';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
||||||
@@ -11,6 +11,8 @@ import { tombstoneUser } from '../utils/userDeletion.js';
|
|||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { resizeProfileImage } from '../utils/thumbnail.js';
|
import { resizeProfileImage } from '../utils/thumbnail.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
|
||||||
|
import { extractDomain } from './federation.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
||||||
@@ -579,6 +581,99 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send({ ok: true, updatedAt });
|
return reply.code(200).send({ ok: true, updatedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/users/@me/federation-identity/delete — request identity deletion on remote instances via S2S
|
||||||
|
app.post<{ Body: FederationIdentityDeleteRequest }>('/api/users/@me/federation-identity/delete', {
|
||||||
|
preHandler: authenticate,
|
||||||
|
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { origins, mode } = request.body;
|
||||||
|
|
||||||
|
if (!mode || !['soft', 'full'].includes(mode)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid mode: must be "soft" or "full"', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(origins) || origins.length === 0 || !origins.every(o => typeof o === 'string')) {
|
||||||
|
return reply.code(400).send({ error: 'origins must be a non-empty array of strings', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
const homeInstance = extractDomain(ourOrigin);
|
||||||
|
|
||||||
|
const results: Record<string, FederationIdentityDeleteResult> = {};
|
||||||
|
|
||||||
|
await Promise.all(origins.map(async (origin) => {
|
||||||
|
try {
|
||||||
|
// Look up peer
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, origin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer || peer.status !== 'active') {
|
||||||
|
results[origin] = { success: false, error: 'no_active_peer' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HMAC-signed request
|
||||||
|
const body = JSON.stringify({
|
||||||
|
homeUserId: request.userId,
|
||||||
|
homeInstance,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = buildFederationHeaders(body, peer.hmacSecret, ourOrigin);
|
||||||
|
|
||||||
|
// Send to remote with 15s timeout
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${origin}/api/federation/identity`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
const data = await response.json() as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
results[origin] = { success: true };
|
||||||
|
} else if (data.error === 'owns_spaces') {
|
||||||
|
results[origin] = {
|
||||||
|
success: false,
|
||||||
|
error: 'owns_spaces',
|
||||||
|
ownedSpaces: data.ownedSpaces as { id: string; name: string }[],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
results[origin] = {
|
||||||
|
success: false,
|
||||||
|
error: (data.error as string) || `HTTP ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
results[origin] = { success: false, error: 'timeout' };
|
||||||
|
} else {
|
||||||
|
results[origin] = { success: false, error: 'unreachable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
results[origin] = {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response: FederationIdentityDeleteResponse = { results };
|
||||||
|
return reply.code(200).send(response);
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
||||||
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
|
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
|
||||||
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user