847 lines
35 KiB
TypeScript
847 lines
35 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { eq, or, and, inArray } from 'drizzle-orm';
|
|
import { getDb, schema } from '../db/index.js';
|
|
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
|
import { connectionManager } from '../ws/handler.js';
|
|
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder, Activity } from '@backspace/shared';
|
|
import { AVATAR_COLORS } from '@backspace/shared';
|
|
import { sanitizeUser } from '../utils/sanitize.js';
|
|
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
|
import { tombstoneUser } from '../utils/userDeletion.js';
|
|
import { generateSnowflake } from '../utils/snowflake.js';
|
|
import { resizeProfileImage } from '../utils/thumbnail.js';
|
|
import { config } from '../config.js';
|
|
import path from 'path';
|
|
|
|
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
|
function isValidAssetUrl(url: string | null | undefined): boolean {
|
|
if (!url || url.trim().length === 0) return true; // empty/null = clearing
|
|
const trimmed = url.trim();
|
|
if (trimmed.startsWith('/api/uploads/')) return true;
|
|
if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) return true;
|
|
// Accept bare filenames (the existing convention) — no slashes, no traversal
|
|
if (!trimmed.includes('/') && !trimmed.includes('\\') && !trimmed.includes('..')) return true;
|
|
return false;
|
|
}
|
|
|
|
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
|
const db = getDb();
|
|
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (!user || user.isDeleted) {
|
|
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
|
|
}
|
|
|
|
return reply.code(200).send(sanitizeUser(user, true));
|
|
});
|
|
|
|
// POST /api/users/@me/verify-password — verify password matches current account
|
|
app.post<{ Body: VerifyPasswordRequest }>('/api/users/@me/verify-password', { preHandler: authenticate }, async (request, reply) => {
|
|
const { password } = request.body;
|
|
|
|
if (!password || typeof password !== 'string') {
|
|
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (!user) {
|
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
|
}
|
|
|
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
const response: VerifyPasswordResponse = { valid };
|
|
return reply.code(200).send(response);
|
|
});
|
|
|
|
// POST /api/users/@me/change-password — change account password
|
|
app.post<{ Body: ChangePasswordRequest }>('/api/users/@me/change-password', {
|
|
preHandler: authenticate,
|
|
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
|
}, async (request, reply) => {
|
|
const { currentPassword, newPassword } = request.body;
|
|
|
|
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
|
return reply.code(400).send({ error: 'New password must be at least 8 characters', statusCode: 400 });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (!user) {
|
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
|
}
|
|
|
|
// Federated users (replicas on this instance) don't need currentPassword —
|
|
// their home instance already verified the password change, and JWT auth
|
|
// proves identity. The homeInstance field comes from the DB, not the request.
|
|
if (!user.homeInstance) {
|
|
// Local users must provide current password
|
|
if (!currentPassword || typeof currentPassword !== 'string') {
|
|
return reply.code(400).send({ error: 'Current password is required', statusCode: 400 });
|
|
}
|
|
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
|
if (!valid) {
|
|
return reply.code(403).send({ error: 'Incorrect password', statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
const newHash = await hashPassword(newPassword);
|
|
db.update(schema.users).set({ passwordHash: newHash, passwordChangedAt: Date.now() }).where(eq(schema.users.id, request.userId)).run();
|
|
|
|
// Issue fresh JWT
|
|
const token = signJwt({ userId: user.id, username: user.username });
|
|
const response: ChangePasswordResponse = { token };
|
|
return reply.code(200).send(response);
|
|
});
|
|
|
|
// DELETE /api/users/@me — delete (tombstone) account
|
|
app.delete<{ Body: DeleteAccountRequest }>('/api/users/@me', {
|
|
preHandler: authenticate,
|
|
config: { rateLimit: { max: 3, timeWindow: '15 minutes' } },
|
|
}, async (request, reply) => {
|
|
const { password, username } = request.body;
|
|
|
|
if (!username || typeof username !== 'string') {
|
|
return reply.code(400).send({ error: 'Username confirmation is required', statusCode: 400 });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (!user) {
|
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
|
}
|
|
|
|
// Verify username matches (confirmation safeguard)
|
|
if (user.username !== username) {
|
|
return reply.code(400).send({ error: 'Username does not match', statusCode: 400 });
|
|
}
|
|
|
|
// Native users must verify password; federated users rely on JWT auth
|
|
if (!user.homeInstance) {
|
|
if (!password || typeof password !== 'string') {
|
|
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
|
|
}
|
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
if (!valid) {
|
|
return reply.code(403).send({ error: 'Incorrect password', statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
// Check if user owns any spaces
|
|
const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name })
|
|
.from(schema.spaces)
|
|
.where(eq(schema.spaces.ownerId, request.userId))
|
|
.all();
|
|
if (ownedSpaces.length > 0) {
|
|
return reply.code(400).send({
|
|
error: 'You must transfer ownership or delete all spaces you own before deleting your account',
|
|
statusCode: 400,
|
|
ownedSpaces,
|
|
});
|
|
}
|
|
|
|
// Tombstone the account (transaction handles all DB cleanup)
|
|
const filesToDelete = tombstoneUser(request.userId);
|
|
|
|
// Clean up files from disk after transaction commits
|
|
for (const filename of filesToDelete) {
|
|
deleteUploadFile(filename);
|
|
}
|
|
|
|
// Force-close all WebSocket connections
|
|
connectionManager.forceDisconnectUser(request.userId);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
|
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity } = request.body;
|
|
const db = getDb();
|
|
|
|
const updateData: Record<string, string | null | undefined> = {};
|
|
|
|
if (displayName !== undefined) {
|
|
if (displayName !== null && typeof displayName === 'string') {
|
|
const trimmed = displayName.trim();
|
|
if (trimmed.length > 32) {
|
|
return reply.code(400).send({ error: 'Display name must be 32 characters or less', statusCode: 400 });
|
|
}
|
|
updateData.displayName = trimmed || null;
|
|
} else {
|
|
updateData.displayName = null;
|
|
}
|
|
}
|
|
|
|
// Track old files for cleanup after update
|
|
let oldAvatar: string | null = null;
|
|
let oldBanner: string | null = null;
|
|
if (avatar !== undefined || banner !== undefined) {
|
|
const current = db.select({ avatar: schema.users.avatar, banner: schema.users.banner })
|
|
.from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
oldAvatar = current?.avatar ?? null;
|
|
oldBanner = current?.banner ?? null;
|
|
}
|
|
|
|
if (avatar !== undefined) {
|
|
if (!isValidAssetUrl(avatar)) {
|
|
return reply.code(400).send({ error: 'Avatar URL must be a relative upload path or http/https URL', statusCode: 400 });
|
|
}
|
|
updateData.avatar = avatar;
|
|
// Normalize to bare filename — profileSync historically stored /api/uploads/ prefix
|
|
if (typeof updateData.avatar === 'string' && updateData.avatar.startsWith('/api/uploads/')) {
|
|
updateData.avatar = updateData.avatar.slice('/api/uploads/'.length);
|
|
}
|
|
}
|
|
|
|
if (banner !== undefined) {
|
|
if (!isValidAssetUrl(banner)) {
|
|
return reply.code(400).send({ error: 'Banner URL must be a relative upload path or http/https URL', statusCode: 400 });
|
|
}
|
|
if (banner && typeof banner === 'string' && banner.trim().length > 0) {
|
|
updateData.banner = banner.trim();
|
|
// Normalize to bare filename — profileSync historically stored /api/uploads/ prefix
|
|
if (updateData.banner.startsWith('/api/uploads/')) {
|
|
updateData.banner = updateData.banner.slice('/api/uploads/'.length);
|
|
}
|
|
} else {
|
|
updateData.banner = null;
|
|
}
|
|
}
|
|
|
|
if (accentColor !== undefined) {
|
|
if (accentColor && typeof accentColor === 'string' && accentColor.trim().length > 0) {
|
|
const hex = accentColor.trim();
|
|
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
|
return reply.code(400).send({ error: 'Accent color must be a valid hex color (e.g. #ff0000)', statusCode: 400 });
|
|
}
|
|
updateData.accentColor = hex;
|
|
} else {
|
|
updateData.accentColor = null;
|
|
}
|
|
}
|
|
|
|
if (avatarColor !== undefined) {
|
|
if (avatarColor && typeof avatarColor === 'string' && avatarColor.trim().length > 0) {
|
|
const trimmed = avatarColor.trim();
|
|
if (!(AVATAR_COLORS as readonly string[]).includes(trimmed)) {
|
|
return reply.code(400).send({ error: `Invalid avatar color. Must be one of: ${AVATAR_COLORS.join(', ')}`, statusCode: 400 });
|
|
}
|
|
updateData.avatarColor = trimmed;
|
|
} else {
|
|
updateData.avatarColor = null;
|
|
}
|
|
}
|
|
|
|
if (bio !== undefined) {
|
|
if (bio && typeof bio === 'string') {
|
|
const trimmed = bio.trim();
|
|
if (trimmed.length > 190) {
|
|
return reply.code(400).send({ error: 'Bio must be 190 characters or less', statusCode: 400 });
|
|
}
|
|
updateData.bio = trimmed || null;
|
|
} else {
|
|
updateData.bio = null;
|
|
}
|
|
}
|
|
|
|
if (customStatus !== undefined) {
|
|
if (customStatus !== null && typeof customStatus === 'string') {
|
|
const trimmed = customStatus.trim();
|
|
if (trimmed.length > 128) {
|
|
return reply.code(400).send({ error: 'Custom status must be 128 characters or less', statusCode: 400 });
|
|
}
|
|
updateData.customStatus = trimmed || null;
|
|
} else {
|
|
updateData.customStatus = null;
|
|
}
|
|
}
|
|
|
|
if (status !== undefined) {
|
|
if (!['online', 'idle', 'dnd', 'offline'].includes(status)) {
|
|
return reply.code(400).send({ error: 'Invalid status', statusCode: 400 });
|
|
}
|
|
updateData.status = status;
|
|
}
|
|
|
|
if (replicatedInstances !== undefined) {
|
|
if (!Array.isArray(replicatedInstances)) {
|
|
return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 });
|
|
}
|
|
if (replicatedInstances.length > 20) {
|
|
return reply.code(400).send({ error: 'Maximum 20 replicated instances', statusCode: 400 });
|
|
}
|
|
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
|
for (const inst of replicatedInstances) {
|
|
if (!inst || typeof inst.username !== 'string' || inst.username.trim().length === 0) {
|
|
return reply.code(400).send({ error: 'Each replicated instance must have a non-empty username string', statusCode: 400 });
|
|
}
|
|
if (inst.username.length > 255) {
|
|
return reply.code(400).send({ error: 'Instance username must be 255 characters or less', statusCode: 400 });
|
|
}
|
|
if (typeof inst.origin !== 'string' && typeof inst.domain !== 'string') {
|
|
return reply.code(400).send({ error: 'Each replicated instance must have origin or domain string', statusCode: 400 });
|
|
}
|
|
if (typeof inst.origin === 'string') {
|
|
if (inst.origin.length > 512) {
|
|
return reply.code(400).send({ error: 'Instance origin must be 512 characters or less', statusCode: 400 });
|
|
}
|
|
if (!inst.origin.startsWith('https://') && !inst.origin.startsWith('http://')) {
|
|
return reply.code(400).send({ error: 'Instance origin must start with https:// or http://', statusCode: 400 });
|
|
}
|
|
}
|
|
if (typeof inst.domain === 'string') {
|
|
if (inst.domain.length > 253) {
|
|
return reply.code(400).send({ error: 'Instance domain must be 253 characters or less', statusCode: 400 });
|
|
}
|
|
if (!domainRegex.test(inst.domain)) {
|
|
return reply.code(400).send({ error: 'Instance domain contains invalid characters', statusCode: 400 });
|
|
}
|
|
}
|
|
}
|
|
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
|
}
|
|
|
|
if (homeUserId !== undefined) {
|
|
// Only allow setting homeUserId for replicated users (has homeInstance)
|
|
const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (currentUser?.homeInstance && typeof homeUserId === 'string' && homeUserId.length > 0) {
|
|
updateData.homeUserId = homeUserId;
|
|
}
|
|
}
|
|
|
|
if (discoverable !== undefined) {
|
|
if (typeof discoverable !== 'boolean') {
|
|
return reply.code(400).send({ error: 'discoverable must be a boolean', statusCode: 400 });
|
|
}
|
|
(updateData as Record<string, unknown>).discoverable = discoverable ? 1 : 0;
|
|
}
|
|
|
|
if (showActivity !== undefined) {
|
|
if (typeof showActivity !== 'boolean') {
|
|
return reply.code(400).send({ error: 'showActivity must be a boolean', statusCode: 400 });
|
|
}
|
|
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
|
|
}
|
|
|
|
if (Object.keys(updateData).length === 0) {
|
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
|
}
|
|
|
|
// LWW guard: if the caller provided a profileUpdatedAt and profile fields changed,
|
|
// reject stale writes by comparing timestamps
|
|
const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'];
|
|
const hasProfileChange = profileFields.some(f => f in updateData);
|
|
|
|
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 ?? currentUser.createdAt;
|
|
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();
|
|
}
|
|
}
|
|
|
|
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
|
|
|
|
// Update activity visibility cache and broadcast clear when toggled off
|
|
if (showActivity !== undefined) {
|
|
connectionManager.setUserShowActivity(request.userId, showActivity);
|
|
if (!showActivity) {
|
|
connectionManager.clearUserActivities(request.userId);
|
|
const userSpaces = connectionManager.getUserSpaces(request.userId);
|
|
const clearPayload = {
|
|
type: 'presence_update' as const,
|
|
userId: request.userId,
|
|
status: connectionManager.getUserStatus(request.userId),
|
|
activities: [] as Activity[],
|
|
};
|
|
for (const spaceId of userSpaces) {
|
|
connectionManager.sendToSpace(spaceId, clearPayload, request.userId);
|
|
}
|
|
connectionManager.sendToUser(request.userId, clearPayload);
|
|
}
|
|
}
|
|
|
|
// Clean up old avatar/banner files that were replaced
|
|
if (avatar !== undefined && oldAvatar && oldAvatar !== (avatar || null) && !oldAvatar.startsWith('http')) {
|
|
deleteUploadFile(oldAvatar);
|
|
deleteAttachmentByFilename(oldAvatar);
|
|
}
|
|
if (banner !== undefined && oldBanner && oldBanner !== (updateData.banner ?? null) && !oldBanner.startsWith('http')) {
|
|
deleteUploadFile(oldBanner);
|
|
deleteAttachmentByFilename(oldBanner);
|
|
}
|
|
// Clean up attachment records for newly-set profile images — the reference
|
|
// now lives in the users table, so the attachment record is unnecessary
|
|
if (avatar && typeof avatar === 'string' && avatar.includes('/api/uploads/')) {
|
|
deleteAttachmentByFilename(avatar);
|
|
}
|
|
if (updateData.banner && typeof updateData.banner === 'string' && updateData.banner.includes('/api/uploads/')) {
|
|
deleteAttachmentByFilename(updateData.banner);
|
|
}
|
|
|
|
// Resize profile images to optimal dimensions (safety net for federation, API clients, etc.)
|
|
if (avatar && typeof avatar === 'string' && !avatar.startsWith('http')) {
|
|
const filePath = path.join(config.uploadDir, path.basename(avatar));
|
|
await resizeProfileImage(filePath, 'avatar');
|
|
}
|
|
if (updateData.banner && typeof updateData.banner === 'string' && !updateData.banner.startsWith('http')) {
|
|
const filePath = path.join(config.uploadDir, path.basename(updateData.banner));
|
|
await resizeProfileImage(filePath, 'banner');
|
|
}
|
|
|
|
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (!updatedUser) {
|
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
|
}
|
|
|
|
const sanitized = sanitizeUser(updatedUser, true);
|
|
|
|
// Broadcast presence update if status changed
|
|
if (status !== undefined) {
|
|
const userSpaces = connectionManager.getUserSpaces(sanitized.id);
|
|
for (const spaceId of userSpaces) {
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'presence_update',
|
|
userId: sanitized.id,
|
|
status: status,
|
|
}, sanitized.id);
|
|
}
|
|
connectionManager.sendToUser(sanitized.id, {
|
|
type: 'presence_update',
|
|
userId: sanitized.id,
|
|
status: status,
|
|
});
|
|
}
|
|
|
|
// Broadcast user_updated for profile field changes (reuse hasProfileChange from LWW guard above)
|
|
if (hasProfileChange) {
|
|
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
|
|
const targetUserIds = new Set<string>();
|
|
|
|
// 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) {
|
|
connectionManager.sendToUser(uid, userUpdatedEvent);
|
|
}
|
|
}
|
|
|
|
return reply.code(200).send(sanitized);
|
|
});
|
|
|
|
// GET /api/users/@me/federation-registry — retrieve persistent federation registry
|
|
app.get('/api/users/@me/federation-registry', { preHandler: authenticate }, async (request, reply) => {
|
|
const userId = request.userId;
|
|
const db = getDb();
|
|
|
|
const entries = db.select().from(schema.userFederationRegistry)
|
|
.where(eq(schema.userFederationRegistry.userId, userId))
|
|
.all();
|
|
|
|
const user = db.select({ federationRegistryUpdatedAt: schema.users.federationRegistryUpdatedAt })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, userId))
|
|
.get();
|
|
|
|
return reply.code(200).send({
|
|
registry: entries.map((e) => ({
|
|
origin: e.origin,
|
|
label: e.label,
|
|
username: e.username,
|
|
remoteUserId: e.remoteUserId,
|
|
status: e.status,
|
|
addedAt: e.addedAt,
|
|
lastConnectedAt: e.lastConnectedAt,
|
|
disconnectedAt: e.disconnectedAt,
|
|
errorMessage: e.errorMessage,
|
|
})),
|
|
updatedAt: user?.federationRegistryUpdatedAt ?? 0,
|
|
});
|
|
});
|
|
|
|
// PUT /api/users/@me/federation-registry — replace federation registry (LWW)
|
|
app.put<{ Body: { registry: Array<{ origin: string; label?: string; username?: string; remoteUserId?: string; status: string; addedAt: number; lastConnectedAt?: number | null; disconnectedAt?: number | null; errorMessage?: string | null }>; updatedAt: number } }>(
|
|
'/api/users/@me/federation-registry', { preHandler: authenticate }, async (request, reply) => {
|
|
const userId = request.userId;
|
|
const { registry, updatedAt } = request.body;
|
|
|
|
if (!Array.isArray(registry)) {
|
|
return reply.code(400).send({ error: 'registry must be an array', statusCode: 400 });
|
|
}
|
|
if (typeof updatedAt !== 'number' || updatedAt <= 0) {
|
|
return reply.code(400).send({ error: 'updatedAt must be a positive number', statusCode: 400 });
|
|
}
|
|
|
|
// Size cap — prevent unbounded registry writes
|
|
if (registry.length > 100) {
|
|
return reply.code(400).send({ error: 'Registry cannot exceed 100 entries', statusCode: 400 });
|
|
}
|
|
|
|
// Duplicate origin check
|
|
const origins = registry.map(e => e.origin);
|
|
if (new Set(origins).size !== origins.length) {
|
|
return reply.code(400).send({ error: 'Duplicate origins are not allowed', statusCode: 400 });
|
|
}
|
|
|
|
const validStatuses = ['connected', 'disconnected', 'unreachable', 'auth_expired'];
|
|
for (const entry of registry) {
|
|
if (!entry || typeof entry.origin !== 'string' || !entry.origin) {
|
|
return reply.code(400).send({ error: 'Each entry must have a string origin', statusCode: 400 });
|
|
}
|
|
if (!validStatuses.includes(entry.status)) {
|
|
return reply.code(400).send({ error: `Invalid status "${entry.status}" — must be one of: ${validStatuses.join(', ')}`, statusCode: 400 });
|
|
}
|
|
if (typeof entry.addedAt !== 'number') {
|
|
return reply.code(400).send({ error: 'Each entry must have a numeric addedAt', statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
// LWW guard: reject if incoming timestamp is not newer than stored
|
|
const user = db.select({ federationRegistryUpdatedAt: schema.users.federationRegistryUpdatedAt })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, userId))
|
|
.get();
|
|
|
|
const storedUpdatedAt = user?.federationRegistryUpdatedAt ?? 0;
|
|
if (updatedAt <= storedUpdatedAt) {
|
|
return reply.code(409).send({ error: 'Conflict: incoming registry is not newer than stored version', statusCode: 409 });
|
|
}
|
|
|
|
// Atomic replace: delete existing, insert all incoming, update timestamp
|
|
db.transaction((tx) => {
|
|
tx.delete(schema.userFederationRegistry)
|
|
.where(eq(schema.userFederationRegistry.userId, userId))
|
|
.run();
|
|
|
|
if (registry.length > 0) {
|
|
tx.insert(schema.userFederationRegistry).values(
|
|
registry.map((e) => ({
|
|
userId,
|
|
origin: e.origin,
|
|
label: e.label ?? '',
|
|
username: e.username ?? '',
|
|
remoteUserId: e.remoteUserId ?? '',
|
|
status: e.status,
|
|
addedAt: e.addedAt,
|
|
lastConnectedAt: e.lastConnectedAt ?? null,
|
|
disconnectedAt: e.disconnectedAt ?? null,
|
|
errorMessage: e.errorMessage ?? null,
|
|
}))
|
|
).run();
|
|
}
|
|
|
|
tx.update(schema.users)
|
|
.set({ federationRegistryUpdatedAt: updatedAt })
|
|
.where(eq(schema.users.id, userId))
|
|
.run();
|
|
});
|
|
|
|
return reply.code(200).send({ ok: true, updatedAt });
|
|
});
|
|
|
|
// 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 } }>(
|
|
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
|
const { items, folders, updatedAt: incomingTs } = request.body;
|
|
const userId = request.userId;
|
|
|
|
if (!Array.isArray(items)) {
|
|
return reply.code(400).send({ error: 'items must be an array', statusCode: 400 });
|
|
}
|
|
if (!folders || typeof folders !== 'object') {
|
|
return reply.code(400).send({ error: 'folders must be an object', statusCode: 400 });
|
|
}
|
|
|
|
// Validate items
|
|
for (const item of items) {
|
|
if (!item || (item.t !== 's' && item.t !== 'f') || typeof item.id !== 'string') {
|
|
return reply.code(400).send({ error: 'Each item must have t ("s" or "f") and id string', statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
// Validate folders
|
|
for (const [key, folder] of Object.entries(folders)) {
|
|
if (!Array.isArray(folder.spaceIds)) {
|
|
return reply.code(400).send({ error: `Folder "${key}" must have spaceIds array`, statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
// LWW guard: reject stale layout writes
|
|
if (incomingTs !== undefined && typeof incomingTs === 'number') {
|
|
const existingLayout = db.select().from(schema.userSpaceLayout)
|
|
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
|
if (existingLayout && incomingTs < existingLayout.updatedAt) {
|
|
// Incoming layout is older — return current state without updating
|
|
const currentItems: SpaceLayoutItem[] = JSON.parse(existingLayout.layout);
|
|
const currentFolderRows = db.select().from(schema.spaceFolders)
|
|
.where(eq(schema.spaceFolders.userId, userId))
|
|
.orderBy(schema.spaceFolders.position)
|
|
.all();
|
|
const currentFolders: SpaceFolder[] = currentFolderRows.map(folder => {
|
|
const memberRows = db.select()
|
|
.from(schema.spaceFolderMembers)
|
|
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
|
|
.orderBy(schema.spaceFolderMembers.position)
|
|
.all();
|
|
return {
|
|
id: folder.id,
|
|
userId: folder.userId,
|
|
name: folder.name,
|
|
color: folder.color,
|
|
position: folder.position ?? 0,
|
|
spaceIds: memberRows.map(m => m.spaceId),
|
|
};
|
|
});
|
|
return reply.code(200).send({ items: currentItems, folders: currentFolders, updatedAt: existingLayout.updatedAt });
|
|
}
|
|
}
|
|
|
|
// Resolve the effective timestamp for this write
|
|
const effectiveTs = (incomingTs !== undefined && typeof incomingTs === 'number') ? incomingTs : Date.now();
|
|
|
|
// Map new:* folder keys to server-generated IDs
|
|
const newIdMap = new Map<string, string>();
|
|
for (const key of Object.keys(folders)) {
|
|
if (key.startsWith('new:')) {
|
|
newIdMap.set(key, generateSnowflake());
|
|
}
|
|
}
|
|
|
|
db.transaction((tx) => {
|
|
// Get existing folder IDs for this user
|
|
const existingFolders = tx.select({ id: schema.spaceFolders.id })
|
|
.from(schema.spaceFolders)
|
|
.where(eq(schema.spaceFolders.userId, userId))
|
|
.all();
|
|
const existingFolderIds = new Set(existingFolders.map(f => f.id));
|
|
|
|
// Determine which folders to keep (ones in the request, with resolved IDs)
|
|
const keepFolderIds = new Set<string>();
|
|
for (const [key, folder] of Object.entries(folders)) {
|
|
const resolvedId = newIdMap.get(key) ?? key;
|
|
keepFolderIds.add(resolvedId);
|
|
|
|
if (key.startsWith('new:')) {
|
|
// Create new folder
|
|
tx.insert(schema.spaceFolders).values({
|
|
id: resolvedId,
|
|
userId,
|
|
name: folder.name,
|
|
color: folder.color,
|
|
position: 0,
|
|
createdAt: Date.now(),
|
|
}).run();
|
|
} else if (existingFolderIds.has(key)) {
|
|
// Update existing folder
|
|
tx.update(schema.spaceFolders)
|
|
.set({ name: folder.name, color: folder.color })
|
|
.where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId)))
|
|
.run();
|
|
} else {
|
|
// Folder from a remote instance — create it locally with the original ID
|
|
tx.insert(schema.spaceFolders).values({
|
|
id: key,
|
|
userId,
|
|
name: folder.name,
|
|
color: folder.color,
|
|
position: 0,
|
|
createdAt: Date.now(),
|
|
}).run();
|
|
}
|
|
|
|
// Clear and re-insert folder members with position
|
|
tx.delete(schema.spaceFolderMembers)
|
|
.where(eq(schema.spaceFolderMembers.folderId, resolvedId))
|
|
.run();
|
|
|
|
for (let i = 0; i < folder.spaceIds.length; i++) {
|
|
const spaceId = folder.spaceIds[i];
|
|
if (!spaceId) continue;
|
|
tx.insert(schema.spaceFolderMembers).values({
|
|
folderId: resolvedId,
|
|
spaceId,
|
|
position: i,
|
|
}).run();
|
|
}
|
|
}
|
|
|
|
// Delete folders that are no longer in the request
|
|
for (const existingId of existingFolderIds) {
|
|
if (!keepFolderIds.has(existingId)) {
|
|
tx.delete(schema.spaceFolderMembers)
|
|
.where(eq(schema.spaceFolderMembers.folderId, existingId))
|
|
.run();
|
|
tx.delete(schema.spaceFolders)
|
|
.where(and(eq(schema.spaceFolders.id, existingId), eq(schema.spaceFolders.userId, userId)))
|
|
.run();
|
|
}
|
|
}
|
|
|
|
// Replace new:* keys in items array with server-generated IDs
|
|
const finalItems: SpaceLayoutItem[] = items.map(item => {
|
|
if (item.t === 'f' && newIdMap.has(item.id)) {
|
|
return { t: 'f' as const, id: newIdMap.get(item.id)! };
|
|
}
|
|
return item;
|
|
});
|
|
|
|
// Upsert user_space_layout
|
|
const existing = tx.select().from(schema.userSpaceLayout)
|
|
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
|
if (existing) {
|
|
tx.update(schema.userSpaceLayout)
|
|
.set({ layout: JSON.stringify(finalItems), updatedAt: effectiveTs })
|
|
.where(eq(schema.userSpaceLayout.userId, userId))
|
|
.run();
|
|
} else {
|
|
tx.insert(schema.userSpaceLayout).values({
|
|
userId,
|
|
layout: JSON.stringify(finalItems),
|
|
updatedAt: effectiveTs,
|
|
}).run();
|
|
}
|
|
});
|
|
|
|
// Build response: fetch final state
|
|
const finalLayout = db.select().from(schema.userSpaceLayout)
|
|
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
|
const finalItems: SpaceLayoutItem[] = finalLayout ? JSON.parse(finalLayout.layout) : [];
|
|
|
|
const finalFolderRows = db.select().from(schema.spaceFolders)
|
|
.where(eq(schema.spaceFolders.userId, userId))
|
|
.orderBy(schema.spaceFolders.position)
|
|
.all();
|
|
|
|
const responseFolders: SpaceFolder[] = [];
|
|
for (const folder of finalFolderRows) {
|
|
const memberRows = db.select()
|
|
.from(schema.spaceFolderMembers)
|
|
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
|
|
.orderBy(schema.spaceFolderMembers.position)
|
|
.all();
|
|
responseFolders.push({
|
|
id: folder.id,
|
|
userId: folder.userId,
|
|
name: folder.name,
|
|
color: folder.color,
|
|
position: folder.position ?? 0,
|
|
spaceIds: memberRows.map(m => m.spaceId),
|
|
});
|
|
}
|
|
|
|
const layoutUpdatedAt = finalLayout?.updatedAt ?? effectiveTs;
|
|
|
|
// Broadcast to user's other connections (multi-tab sync)
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'space_layout_updated',
|
|
layout: finalItems,
|
|
folders: responseFolders,
|
|
updatedAt: layoutUpdatedAt,
|
|
});
|
|
|
|
return reply.code(200).send({ items: finalItems, folders: responseFolders, updatedAt: layoutUpdatedAt });
|
|
});
|
|
|
|
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
|
|
if (!user) {
|
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
|
}
|
|
|
|
return reply.code(200).send(sanitizeUser(user));
|
|
});
|
|
|
|
app.get<{ Params: { id: string }; Querystring: { homeUserId?: string } }>(
|
|
'/api/users/:id/mutuals', { preHandler: authenticate }, async (request, reply) => {
|
|
const { id: targetId } = request.params;
|
|
const homeUserId = request.query.homeUserId;
|
|
const myId = request.userId;
|
|
const db = getDb();
|
|
|
|
// Resolve target: try path ID first, then homeUserId fallback (federation)
|
|
let resolvedTargetId = targetId;
|
|
const directUser = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
|
|
if (!directUser && homeUserId) {
|
|
const fallbackUser = db.select().from(schema.users)
|
|
.where(or(eq(schema.users.homeUserId, homeUserId), eq(schema.users.id, homeUserId))).get();
|
|
if (fallbackUser) resolvedTargetId = fallbackUser.id;
|
|
}
|
|
|
|
// Mutual friends: users who are friends with both me and the target
|
|
const myFriendRows = db.select().from(schema.friends).where(
|
|
or(eq(schema.friends.userId, myId), eq(schema.friends.friendId, myId))
|
|
).all();
|
|
const targetFriendRows = db.select().from(schema.friends).where(
|
|
or(eq(schema.friends.userId, resolvedTargetId), eq(schema.friends.friendId, resolvedTargetId))
|
|
).all();
|
|
const myFriendIds = new Set(myFriendRows.map(f => f.userId === myId ? f.friendId : f.userId));
|
|
const targetFriendIds = new Set(targetFriendRows.map(f => f.userId === resolvedTargetId ? f.friendId : f.userId));
|
|
const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id));
|
|
|
|
const mutualFriends = mutualFriendIds.length > 0
|
|
? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(u => sanitizeUser(u))
|
|
: [];
|
|
|
|
// Mutual spaces: spaces both me and the target are members of
|
|
const myMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, myId)).all();
|
|
const targetMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, resolvedTargetId)).all();
|
|
const mySpaceIds = new Set(myMemberships.map((m) => m.spaceId));
|
|
const targetSpaceIds = new Set(targetMemberships.map((m) => m.spaceId));
|
|
const mutualSpaceIds = [...mySpaceIds].filter((id) => targetSpaceIds.has(id));
|
|
|
|
const mutualSpaces = mutualSpaceIds.length > 0
|
|
? db.select({ id: schema.spaces.id, name: schema.spaces.name, icon: schema.spaces.icon, avatarColor: schema.spaces.avatarColor })
|
|
.from(schema.spaces)
|
|
.where(inArray(schema.spaces.id, mutualSpaceIds))
|
|
.all()
|
|
: [];
|
|
|
|
return reply.code(200).send({ mutualFriends, mutualSpaces });
|
|
});
|
|
}
|