Files
backspace/packages/server/src/routes/social.ts
T
Jannis Braun 699c5a4365 fix(federation): include profile snapshots in friend relay events
Replicated user stubs created by resolveOrCreateReplicatedUser had
null avatar/displayName, causing blank profiles in the UI until
page refresh. Friend relay events now carry profile snapshots
(displayName, avatar, avatarColor, banner, bio) so the receiving
instance can hydrate stubs with real data.
2026-03-27 01:26:46 +01:00

703 lines
25 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from '../ws/handler.js';
import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared';
import type {
Friend,
FriendRequest,
SendFriendRequest,
UpdateFriendRequest,
DiscoverUser,
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot {
return {
displayName: user.displayName ?? null,
avatar: user.avatar ?? null,
avatarColor: user.avatarColor ?? null,
banner: user.banner ?? null,
bio: user.bio ?? null,
};
}
export async function socialRoutes(app: FastifyInstance): Promise<void> {
// GET /api/social/friends - List all friends
app.get('/api/social/friends', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
// Get all friends where current user is either userId or friendId
const friendRows = db.select()
.from(schema.friends)
.where(or(
eq(schema.friends.userId, request.userId),
eq(schema.friends.friendId, request.userId)
))
.all();
if (friendRows.length === 0) {
return reply.code(200).send([]);
}
// Get the IDs of the actual friends (not the current user)
const friendIds = friendRows.map(f => f.userId === request.userId ? f.friendId : f.userId);
const friendUsers = db.select()
.from(schema.users)
.where(or(...friendIds.map(id => eq(schema.users.id, id))))
.all();
const friends: Friend[] = friendUsers.map(u => {
const relationship = friendRows.find(f => f.userId === u.id || f.friendId === u.id);
return {
...sanitizeUser(u),
addedAt: relationship?.createdAt ?? Date.now(),
};
});
return reply.code(200).send(friends);
});
// GET /api/social/requests - List pending friend requests
app.get('/api/social/requests', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const requests = db.select()
.from(schema.friendRequests)
.where(and(
or(
eq(schema.friendRequests.fromId, request.userId),
eq(schema.friendRequests.toId, request.userId)
),
eq(schema.friendRequests.status, 'pending')
))
.all();
if (requests.length === 0) {
return reply.code(200).send([]);
}
// Enhance with user data
const userIds = requests.map(r => r.fromId === request.userId ? r.toId : r.fromId);
const users = db.select()
.from(schema.users)
.where(or(...userIds.map(id => eq(schema.users.id, id))))
.all();
const userMap = new Map(users.map(u => [u.id, u]));
const result: FriendRequest[] = requests.map(r => {
const otherId = r.fromId === request.userId ? r.toId : r.fromId;
const otherUser = userMap.get(otherId);
return {
id: r.id,
fromId: r.fromId,
toId: r.toId,
status: (r.status ?? 'pending') as any,
createdAt: r.createdAt,
user: otherUser ? sanitizeUser(otherUser) : undefined,
};
});
return reply.code(200).send(result);
});
// POST /api/social/requests - Send a friend request
app.post<{ Body: SendFriendRequest }>('/api/social/requests', {
preHandler: authenticate,
}, async (request, reply) => {
const { username } = request.body;
const db = getDb();
if (!username) {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
// Find the target user
const targetUser = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (targetUser.id === request.userId) {
return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 });
}
// Check if already friends
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId))
)).get();
if (existingFriend) {
return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)),
and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId))
),
eq(schema.friendRequests.status, 'pending')
)).get();
if (existingRequest) {
return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 });
}
// Create the request
const id = generateSnowflake();
const now = Date.now();
db.insert(schema.friendRequests).values({
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
}).run();
// Get the sender user for the WS event
const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Broadcast friend_request_received to the target user
const friendRequestPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: senderUser ? sanitizeUser(senderUser) : undefined,
};
connectionManager.sendToUser(targetUser.id, {
type: 'friend_request_received',
request: friendRequestPayload,
});
// Federation relay: notify the target user's home instance
const domainOrigin = getOurOrigin();
const fromIdentity = {
homeUserId: senderUser?.homeUserId || request.userId,
homeInstance: senderUser?.homeInstance || domainOrigin,
};
const toIdentity = {
homeUserId: targetUser.homeUserId || targetUser.id,
homeInstance: targetUser.homeInstance || domainOrigin,
};
const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const payload: FederationRelayEvent = {
eventType: 'friend_request_create',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined,
toProfile: buildProfileSnapshot(targetUser),
status: 'pending',
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, targets, 'friend');
}
return reply.code(201).send({ success: true, requestId: id });
});
// PATCH /api/social/requests/:id - Accept/Decline a friend request
app.patch<{ Params: { id: string }; Body: UpdateFriendRequest }>('/api/social/requests/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { status } = request.body;
const db = getDb();
if (!['accepted', 'declined'].includes(status)) {
return reply.code(400).send({ error: 'Invalid status', statusCode: 400 });
}
const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get();
if (!friendRequest) {
return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 });
}
if (friendRequest.toId !== request.userId) {
return reply.code(403).send({ error: 'You can only manage requests sent to you', statusCode: 403 });
}
if (status === 'accepted') {
// Insert friend and update request status atomically
const now = Date.now();
db.transaction((tx) => {
tx.insert(schema.friends).values({
userId: friendRequest.fromId,
friendId: friendRequest.toId,
createdAt: now,
}).run();
tx.update(schema.friendRequests)
.set({ status })
.where(eq(schema.friendRequests.id, id))
.run();
});
// WS broadcast AFTER transaction commits
const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (acceptingUser) {
const friend: Friend = {
...sanitizeUser(acceptingUser),
addedAt: now,
};
connectionManager.sendToUser(friendRequest.fromId, {
type: 'friend_request_accepted',
friend,
requestId: id,
});
}
} else {
// For declined, just update the status (single write, no transaction needed)
db.update(schema.friendRequests)
.set({ status })
.where(eq(schema.friendRequests.id, id))
.run();
// Broadcast to the original sender so their UI updates in real-time
connectionManager.sendToUser(friendRequest.fromId, {
type: 'friend_request_declined',
requestId: id,
userId: request.userId,
});
}
// Federation relay: notify the other user's home instance
const domainOrigin = getOurOrigin();
const fromUser = db.select().from(schema.users).where(eq(schema.users.id, friendRequest.fromId)).get();
const toUser = db.select().from(schema.users).where(eq(schema.users.id, friendRequest.toId)).get();
if (fromUser && toUser) {
const fromIdentity = {
homeUserId: fromUser.homeUserId || fromUser.id,
homeInstance: fromUser.homeInstance || domainOrigin,
};
const toIdentity = {
homeUserId: toUser.homeUserId || toUser.id,
homeInstance: toUser.homeInstance || domainOrigin,
};
const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const now2 = Date.now();
// Relay request status update
const updateEntityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now2}`;
const updatePayload: FederationRelayEvent = {
eventType: 'friend_request_update',
contextType: 'friend',
messageId: updateEntityId,
encryptionVersion: 0,
timestamp: now2,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: buildProfileSnapshot(fromUser),
toProfile: buildProfileSnapshot(toUser),
status: status as 'accepted' | 'declined',
createdAt: friendRequest.createdAt,
},
};
const updateStr = JSON.stringify(updatePayload);
appendMutationLog(updateEntityId, contextId, 'friend_request_update', updateStr, 'friend');
queueOutboxEvent(updateEntityId, contextId, 'friend_request_update', updateStr, targets, 'friend');
// If accepted, also relay friend_add
if (status === 'accepted') {
const addEntityId = `friend:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now2}`;
const addPayload: FederationRelayEvent = {
eventType: 'friend_add',
contextType: 'friend',
messageId: addEntityId,
encryptionVersion: 0,
timestamp: now2,
friendship: {
from: fromIdentity,
to: toIdentity,
fromProfile: buildProfileSnapshot(fromUser),
toProfile: buildProfileSnapshot(toUser),
createdAt: now2,
},
};
const addStr = JSON.stringify(addPayload);
appendMutationLog(addEntityId, contextId, 'friend_add', addStr, 'friend');
queueOutboxEvent(addEntityId, contextId, 'friend_add', addStr, targets, 'friend');
}
}
}
return reply.code(200).send({ success: true });
});
// DELETE /api/social/requests/:id - Cancel an outgoing friend request
app.delete<{ Params: { id: string } }>('/api/social/requests/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get();
if (!friendRequest) {
return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 });
}
// Only the sender can cancel an outgoing request
if (friendRequest.fromId !== request.userId) {
return reply.code(403).send({ error: 'You can only cancel requests you sent', statusCode: 403 });
}
if (friendRequest.status !== 'pending') {
return reply.code(400).send({ error: 'Can only cancel pending requests', statusCode: 400 });
}
db.delete(schema.friendRequests)
.where(eq(schema.friendRequests.id, id))
.run();
// Broadcast to the recipient so their UI updates in real-time
connectionManager.sendToUser(friendRequest.toId, {
type: 'friend_request_cancelled',
requestId: id,
userId: request.userId,
});
// Federation relay: notify the recipient's home instance
const domainOrigin = getOurOrigin();
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const recipientUser = db.select().from(schema.users).where(eq(schema.users.id, friendRequest.toId)).get();
if (callerUser && recipientUser) {
const fromIdentity = {
homeUserId: callerUser.homeUserId || callerUser.id,
homeInstance: callerUser.homeInstance || domainOrigin,
};
const toIdentity = {
homeUserId: recipientUser.homeUserId || recipientUser.id,
homeInstance: recipientUser.homeInstance || domainOrigin,
};
const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId);
const now = Date.now();
const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`;
const payload: FederationRelayEvent = {
eventType: 'friend_request_cancel',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: fromIdentity,
to: toIdentity,
createdAt: friendRequest.createdAt,
},
};
const payloadStr = JSON.stringify(payload);
appendMutationLog(entityId, contextId, 'friend_request_cancel', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_request_cancel', payloadStr, targets, 'friend');
}
}
return reply.code(200).send({ success: true });
});
// DELETE /api/social/friends/:id - Remove a friend
app.delete<{ Params: { id: string } }>('/api/social/friends/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
// Check friendship exists before deleting
const existing = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
)).get();
if (!existing) {
return reply.code(404).send({ error: 'You are not friends with this user', statusCode: 404 });
}
db.delete(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
)).run();
// Broadcast friend_removed to the other user so their friends list updates
connectionManager.sendToUser(id, {
type: 'friend_removed',
userId: request.userId,
});
// Federation relay: notify the other user's home instance
const domainOrigin = getOurOrigin();
const callerUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const otherUser = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
if (callerUser && otherUser) {
const callerIdentity = {
homeUserId: callerUser.homeUserId || callerUser.id,
homeInstance: callerUser.homeInstance || domainOrigin,
};
const otherIdentity = {
homeUserId: otherUser.homeUserId || otherUser.id,
homeInstance: otherUser.homeInstance || domainOrigin,
};
const targets = getFriendEventTargets(callerIdentity.homeInstance, otherIdentity.homeInstance);
if (targets.length > 0) {
const contextId = buildFriendContextId(callerIdentity.homeUserId, otherIdentity.homeUserId);
const now = Date.now();
const entityId = `friend:${[callerIdentity.homeUserId, otherIdentity.homeUserId].sort().join(':')}:${now}`;
const payload: FederationRelayEvent = {
eventType: 'friend_remove',
contextType: 'friend',
messageId: entityId,
encryptionVersion: 0,
timestamp: now,
friendship: {
from: callerIdentity,
to: otherIdentity,
createdAt: now,
},
};
const payloadStr = JSON.stringify(payload);
appendMutationLog(entityId, contextId, 'friend_remove', payloadStr, 'friend');
queueOutboxEvent(entityId, contextId, 'friend_remove', payloadStr, targets, 'friend');
}
}
return reply.code(200).send({ success: true });
});
// GET /api/social/discover - Discover users on this instance
app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/social/discover', {
preHandler: authenticate,
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
}, async (request, reply) => {
const db = getDb();
const q = request.query.q?.trim() || '';
const limit = Math.min(Math.max(parseInt(request.query.limit || '24', 10) || 24, 1), 100);
const offset = Math.max(parseInt(request.query.offset || '0', 10) || 0, 0);
const myId = request.userId;
// Build WHERE clause
const conditions = [
eq(schema.users.discoverable, 1),
eq(schema.users.isDeleted, 0),
ne(schema.users.id, myId),
// Exclude replicated federated users — each instance only surfaces its own native users.
// Federated users are discovered through the parallel fetch across connected instances.
sql`(${schema.users.homeInstance} IS NULL OR ${schema.users.homeInstance} = '')`,
];
if (q) {
const pattern = `%${q}%`;
conditions.push(or(
like(schema.users.username, pattern),
like(schema.users.displayName, pattern),
)!);
}
// Get total count
const countResult = db.select({ count: sql<number>`count(*)` })
.from(schema.users)
.where(and(...conditions))
.get();
const total = countResult?.count ?? 0;
if (total === 0) {
return reply.code(200).send({ users: [], total: 0 });
}
// Pre-load my social graph
const myFriendRows = db.select().from(schema.friends).where(
or(eq(schema.friends.userId, myId), eq(schema.friends.friendId, myId))
).all();
const myFriendIds = new Set(myFriendRows.map(f => f.userId === myId ? f.friendId : f.userId));
const mySpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, myId))
.all();
const mySpaceIds = new Set(mySpaceRows.map(s => s.spaceId));
const outboundRequests = db.select().from(schema.friendRequests).where(
and(eq(schema.friendRequests.fromId, myId), eq(schema.friendRequests.status, 'pending'))
).all();
const outboundMap = new Map(outboundRequests.map(r => [r.toId, r.id]));
const inboundRequests = db.select().from(schema.friendRequests).where(
and(eq(schema.friendRequests.toId, myId), eq(schema.friendRequests.status, 'pending'))
).all();
const inboundMap = new Map(inboundRequests.map(r => [r.fromId, r.id]));
// Fetch page of users
const userRows = db.select()
.from(schema.users)
.where(and(...conditions))
.orderBy(sql`created_at DESC`)
.limit(limit)
.offset(offset)
.all();
// Batch fetch friends and space memberships for all page users
const pageUserIds = userRows.map(r => r.id);
// Batch fetch all friends for page users
const pageFriendRows = pageUserIds.length > 0
? db.select().from(schema.friends).where(
or(
inArray(schema.friends.userId, pageUserIds),
inArray(schema.friends.friendId, pageUserIds),
)
).all()
: [];
// Build Map<userId, Set<friendId>> for page users
const friendIdsByUser = new Map<string, Set<string>>();
for (const f of pageFriendRows) {
// Map both directions
if (pageUserIds.includes(f.userId)) {
if (!friendIdsByUser.has(f.userId)) friendIdsByUser.set(f.userId, new Set());
friendIdsByUser.get(f.userId)!.add(f.friendId);
}
if (pageUserIds.includes(f.friendId)) {
if (!friendIdsByUser.has(f.friendId)) friendIdsByUser.set(f.friendId, new Set());
friendIdsByUser.get(f.friendId)!.add(f.userId);
}
}
// Batch fetch all space memberships for page users
const pageSpaceMemberRows = pageUserIds.length > 0
? db.select({ userId: schema.spaceMembers.userId, spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(inArray(schema.spaceMembers.userId, pageUserIds))
.all()
: [];
// Build Map<userId, Set<spaceId>> for page users
const spaceIdsByUser = new Map<string, Set<string>>();
for (const sm of pageSpaceMemberRows) {
if (!spaceIdsByUser.has(sm.userId)) spaceIdsByUser.set(sm.userId, new Set());
spaceIdsByUser.get(sm.userId)!.add(sm.spaceId);
}
// Compute mutual counts + relationship for each user
const discoverUsers: DiscoverUser[] = userRows.map(row => {
const u = sanitizeUser(row);
// Mutual friends (using batch-fetched data)
const theirFriendIds = friendIdsByUser.get(row.id) ?? new Set();
const mutualFriendCount = [...myFriendIds].filter(id => theirFriendIds.has(id)).length;
// Mutual spaces (using batch-fetched data)
const theirSpaceIds = spaceIdsByUser.get(row.id) ?? new Set();
const mutualSpaceCount = [...mySpaceIds].filter(id => theirSpaceIds.has(id)).length;
// Relationship
let relationship: DiscoverUser['relationship'] = 'none';
let requestId: string | undefined;
if (myFriendIds.has(row.id)) {
relationship = 'friends';
} else if (outboundMap.has(row.id)) {
relationship = 'outbound_pending';
requestId = outboundMap.get(row.id);
} else if (inboundMap.has(row.id)) {
relationship = 'inbound_pending';
requestId = inboundMap.get(row.id);
}
return {
id: u.id,
username: u.username,
displayName: u.displayName,
avatar: u.avatar,
banner: u.banner,
avatarColor: u.avatarColor,
bio: u.bio,
status: u.status,
customStatus: u.customStatus,
createdAt: u.createdAt,
homeInstance: u.homeInstance,
homeUserId: u.homeUserId,
mutualFriendCount,
mutualSpaceCount,
relationship,
...(requestId ? { requestId } : {}),
};
});
// Sort: mutual friends DESC, then created_at DESC
discoverUsers.sort((a, b) => {
if (b.mutualFriendCount !== a.mutualFriendCount) return b.mutualFriendCount - a.mutualFriendCount;
return b.createdAt - a.createdAt;
});
return reply.code(200).send({ users: discoverUsers, total });
});
// GET /api/social/search?q=... - Search for users to add as friends
app.get<{ Querystring: { q: string } }>('/api/social/search', {
preHandler: authenticate,
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
}, async (request, reply) => {
const { q } = request.query;
const db = getDb();
if (!q || q.length < 1) {
return reply.code(200).send([]);
}
const pattern = `%${q}%`;
// Search by username or display name with partial matching, excluding current user
const users = db.select()
.from(schema.users)
.where(and(
or(
like(schema.users.username, pattern),
like(schema.users.displayName, pattern)
),
ne(schema.users.id, request.userId)
))
.limit(10)
.all();
return reply.code(200).send(users.map(u => sanitizeUser(u)));
});
}