feat: S2S profile sync — write-protection, relay queueing, bootstrap trigger, broadcast refactor
- Add write-protection guard: replicated users can't update durable profile fields (403) - Remove LWW guard (replaced by S2S version check) - Add change detection + relay queueing for profile_update events - Add bootstrap trigger when new origins appear in replicatedInstances - Refactor broadcast to use collectProfileBroadcastTargetIds shared helper
This commit is contained in:
@@ -3,11 +3,12 @@ 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, FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationIdentityDeleteResult } from '@backspace/shared';
|
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity, FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationIdentityDeleteResult, FederationProfileUpdatePayload } 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';
|
||||||
import { tombstoneUser, collectDeletionBroadcastTargets } from '../utils/userDeletion.js';
|
import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcastTargetIds } from '../utils/userDeletion.js';
|
||||||
|
import { queueOutboxEvent, isFederationRelayEnabled } from '../utils/federationOutbox.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';
|
||||||
@@ -185,6 +186,24 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
const updateData: Record<string, string | null | undefined> = {};
|
||||||
|
|
||||||
|
// Fetch the current user row upfront — used for:
|
||||||
|
// 1. Write-protection guard (replicated users can't update durable fields)
|
||||||
|
// 2. Change detection (only relay if values actually differ)
|
||||||
|
const DURABLE_PROFILE_FIELDS = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio'] as const;
|
||||||
|
const preUpdateUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
if (!preUpdateUser) {
|
||||||
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-protection: replicated users cannot update durable profile fields.
|
||||||
|
// These are managed by the home instance via S2S relay.
|
||||||
|
if (preUpdateUser.homeInstance) {
|
||||||
|
const hasDurableField = DURABLE_PROFILE_FIELDS.some(f => (request.body as Record<string, unknown>)[f] !== undefined);
|
||||||
|
if (hasDurableField) {
|
||||||
|
return reply.code(403).send({ error: 'Profile fields are managed by your home instance', statusCode: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (displayName !== undefined) {
|
if (displayName !== undefined) {
|
||||||
if (displayName !== null && typeof displayName === 'string') {
|
if (displayName !== null && typeof displayName === 'string') {
|
||||||
const trimmed = displayName.trim();
|
const trimmed = displayName.trim();
|
||||||
@@ -324,6 +343,49 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
||||||
|
|
||||||
|
// Bootstrap: if new remote origins appeared, push a profile_update to each new peer.
|
||||||
|
// preUpdateUser already has the full row including replicatedInstances and all profile fields.
|
||||||
|
if (isFederationRelayEnabled() && !preUpdateUser.homeInstance) {
|
||||||
|
const existingOrigins = new Set<string>(
|
||||||
|
preUpdateUser.replicatedInstances
|
||||||
|
? (JSON.parse(preUpdateUser.replicatedInstances) as ReplicatedInstance[]).map(ri => ri.origin)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const newOrigins = replicatedInstances
|
||||||
|
.filter(ri => ri.origin && !existingOrigins.has(ri.origin))
|
||||||
|
.map(ri => ri.origin);
|
||||||
|
|
||||||
|
if (newOrigins.length > 0) {
|
||||||
|
const origin = getOurOrigin();
|
||||||
|
const profilePayload: FederationProfileUpdatePayload = {
|
||||||
|
homeUserId: preUpdateUser.id,
|
||||||
|
homeInstance: origin,
|
||||||
|
profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(),
|
||||||
|
displayName: preUpdateUser.displayName,
|
||||||
|
avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http')
|
||||||
|
? `${origin}/api/uploads/${preUpdateUser.avatar}`
|
||||||
|
: preUpdateUser.avatar,
|
||||||
|
banner: preUpdateUser.banner && !preUpdateUser.banner.startsWith('http')
|
||||||
|
? `${origin}/api/uploads/${preUpdateUser.banner}`
|
||||||
|
: preUpdateUser.banner,
|
||||||
|
accentColor: preUpdateUser.accentColor,
|
||||||
|
avatarColor: preUpdateUser.avatarColor,
|
||||||
|
bio: preUpdateUser.bio,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const targetOrigin of newOrigins) {
|
||||||
|
queueOutboxEvent(
|
||||||
|
preUpdateUser.id,
|
||||||
|
preUpdateUser.id,
|
||||||
|
'profile_update',
|
||||||
|
JSON.stringify({ profileUpdate: profilePayload }),
|
||||||
|
[targetOrigin],
|
||||||
|
'profile',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (homeUserId !== undefined) {
|
if (homeUserId !== undefined) {
|
||||||
@@ -352,27 +414,12 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// LWW guard: if the caller provided a profileUpdatedAt and profile fields changed,
|
// Detect profile changes and stamp timestamp
|
||||||
// reject stale writes by comparing timestamps
|
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'].some(f => f in updateData);
|
||||||
const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'];
|
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
|
||||||
const hasProfileChange = profileFields.some(f => f in updateData);
|
if (hasDurableChange) {
|
||||||
|
|
||||||
if (hasProfileChange) {
|
|
||||||
if (profileUpdatedAt !== undefined && typeof profileUpdatedAt === 'number') {
|
|
||||||
const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
||||||
if (currentUser) {
|
|
||||||
const storedTs = currentUser.profileUpdatedAt ?? 0;
|
|
||||||
if (profileUpdatedAt < storedTs) {
|
|
||||||
// Incoming data is older — return current state without updating
|
|
||||||
return reply.code(200).send(sanitizeUser(currentUser, true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(updateData as Record<string, unknown>).profileUpdatedAt = profileUpdatedAt;
|
|
||||||
} else {
|
|
||||||
// No explicit timestamp — stamp with server time (local edits)
|
|
||||||
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
|
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
|
||||||
|
|
||||||
@@ -447,47 +494,52 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast user_updated for profile field changes (reuse hasProfileChange from LWW guard above)
|
// Broadcast user_updated for profile field changes
|
||||||
if (hasProfileChange) {
|
if (hasProfileChange) {
|
||||||
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
|
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
|
||||||
const targetUserIds = new Set<string>();
|
const targetUserIds = collectProfileBroadcastTargetIds(sanitized.id);
|
||||||
|
targetUserIds.add(sanitized.id); // Include self (for other tabs/connections)
|
||||||
// 1. Collect online users who share a space
|
|
||||||
const userSpaces = connectionManager.getUserSpaces(sanitized.id);
|
|
||||||
for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
|
||||||
for (const spaceId of userSpaces) {
|
|
||||||
if (spaceIds.has(spaceId)) { targetUserIds.add(uid); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Collect DM channel co-members
|
|
||||||
const dmMemberships = db.select().from(schema.dmMembers)
|
|
||||||
.where(eq(schema.dmMembers.userId, sanitized.id)).all();
|
|
||||||
for (const dm of dmMemberships) {
|
|
||||||
const coMembers = db.select().from(schema.dmMembers)
|
|
||||||
.where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId)).all();
|
|
||||||
for (const m of coMembers) targetUserIds.add(m.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Collect friends
|
|
||||||
const friendRows = db.select().from(schema.friends)
|
|
||||||
.where(or(
|
|
||||||
eq(schema.friends.userId, sanitized.id),
|
|
||||||
eq(schema.friends.friendId, sanitized.id),
|
|
||||||
)).all();
|
|
||||||
for (const fr of friendRows) {
|
|
||||||
targetUserIds.add(fr.userId === sanitized.id ? fr.friendId : fr.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Include self (for other tabs/connections)
|
|
||||||
targetUserIds.add(sanitized.id);
|
|
||||||
|
|
||||||
// Send deduplicated — each user gets the event exactly once
|
|
||||||
for (const uid of targetUserIds) {
|
for (const uid of targetUserIds) {
|
||||||
connectionManager.sendToUser(uid, userUpdatedEvent);
|
connectionManager.sendToUser(uid, userUpdatedEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S2S profile relay: queue profile_update to all peers when durable fields actually changed
|
||||||
|
if (hasDurableChange && !preUpdateUser.homeInstance && isFederationRelayEnabled()) {
|
||||||
|
// Compare pre-update row against post-update to detect actual value changes
|
||||||
|
const durableActuallyChanged = DURABLE_PROFILE_FIELDS.some(f =>
|
||||||
|
f in updateData && (updateData[f] ?? null) !== (preUpdateUser[f] ?? null)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (durableActuallyChanged) {
|
||||||
|
const origin = getOurOrigin();
|
||||||
|
const profilePayload: FederationProfileUpdatePayload = {
|
||||||
|
homeUserId: updatedUser!.id,
|
||||||
|
homeInstance: origin,
|
||||||
|
profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(),
|
||||||
|
displayName: updatedUser!.displayName,
|
||||||
|
avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http')
|
||||||
|
? `${origin}/api/uploads/${updatedUser!.avatar}`
|
||||||
|
: updatedUser!.avatar,
|
||||||
|
banner: updatedUser!.banner && !updatedUser!.banner.startsWith('http')
|
||||||
|
? `${origin}/api/uploads/${updatedUser!.banner}`
|
||||||
|
: updatedUser!.banner,
|
||||||
|
accentColor: updatedUser!.accentColor,
|
||||||
|
avatarColor: updatedUser!.avatarColor,
|
||||||
|
bio: updatedUser!.bio,
|
||||||
|
};
|
||||||
|
|
||||||
|
queueOutboxEvent(
|
||||||
|
updatedUser!.id, // entityId — user's ID (coalesces rapid edits)
|
||||||
|
updatedUser!.id, // contextId — user-scoped
|
||||||
|
'profile_update',
|
||||||
|
JSON.stringify({ profileUpdate: profilePayload }),
|
||||||
|
undefined, // targetPeerOrigins — broadcast to all active peers
|
||||||
|
'profile', // contextType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send(sanitized);
|
return reply.code(200).send(sanitized);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user