diff --git a/CLAUDE.md b/CLAUDE.md index af483638..8f5a66b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -318,6 +318,7 @@ CREATE TABLE users ( avatar_color TEXT, -- avatar background color bio TEXT, -- user biography is_deleted INTEGER DEFAULT 0, -- soft-delete flag + password_changed_at INTEGER, -- token revocation: tokens issued before this are rejected created_at INTEGER NOT NULL ); @@ -400,6 +401,7 @@ CREATE TABLE attachments ( id TEXT PRIMARY KEY, message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, dm_message_id TEXT, + uploader_id TEXT, -- user who uploaded (null for legacy uploads) filename TEXT NOT NULL, original_name TEXT NOT NULL, mimetype TEXT NOT NULL, diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 6379864a..1944797e 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -39,6 +39,7 @@ export const config = { host: env('HOST', '0.0.0.0'), jwtSecret: env('JWT_SECRET'), jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'), + domain: envOptional('DOMAIN'), livekit: { url: envOptional('LIVEKIT_URL'), @@ -51,3 +52,10 @@ export const config = { maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600), registrationOpen: envBool('REGISTRATION_OPEN', true), } as const; + +if (config.jwtSecret.length < 32) { + throw new Error( + `JWT_SECRET must be at least 32 characters (got ${config.jwtSecret.length}). ` + + `Generate one with: openssl rand -hex 32` + ); +} diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 8dd5bcfd..20e12f81 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -24,8 +24,18 @@ function createTables(db: Database.Database): void { avatar TEXT, status TEXT DEFAULT 'offline', custom_status TEXT, + is_admin INTEGER DEFAULT 0, home_instance TEXT, + home_user_id TEXT, replicated_instances TEXT DEFAULT '[]', + banner TEXT, + accent_color TEXT, + avatar_color TEXT, + bio TEXT, + is_deleted INTEGER DEFAULT 0, + discoverable INTEGER DEFAULT 1, + profile_updated_at INTEGER, + password_changed_at INTEGER, created_at INTEGER NOT NULL ); @@ -33,8 +43,12 @@ function createTables(db: Database.Database): void { id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT, + banner TEXT, + avatar_color TEXT, owner_id TEXT NOT NULL REFERENCES users(id), invite_code TEXT UNIQUE, + visibility TEXT DEFAULT 'private', + description TEXT, created_at INTEGER NOT NULL ); @@ -46,6 +60,14 @@ function createTables(db: Database.Database): void { PRIMARY KEY (space_id, user_id) ); + CREATE TABLE IF NOT EXISTS channel_categories ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS channels ( id TEXT PRIMARY KEY, space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, @@ -53,6 +75,7 @@ function createTables(db: Database.Database): void { type TEXT NOT NULL, topic TEXT, position INTEGER DEFAULT 0, + category_id TEXT, created_at INTEGER NOT NULL ); @@ -69,10 +92,13 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, + dm_message_id TEXT, + uploader_id TEXT, filename TEXT NOT NULL, original_name TEXT NOT NULL, mimetype TEXT NOT NULL, size INTEGER NOT NULL, + thumbnail_filename TEXT, created_at INTEGER NOT NULL ); @@ -85,6 +111,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS dm_members ( dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + closed INTEGER DEFAULT 0, PRIMARY KEY (dm_channel_id, user_id) ); @@ -92,7 +119,9 @@ function createTables(db: Database.Database): void { id TEXT PRIMARY KEY, dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), + reply_to_id TEXT REFERENCES dm_messages(id) ON DELETE SET NULL, content TEXT, + edited_at INTEGER, created_at INTEGER NOT NULL ); @@ -122,7 +151,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS dm_reactions ( id TEXT PRIMARY KEY, - dm_message_id TEXT NOT NULL, + dm_message_id TEXT NOT NULL REFERENCES dm_messages(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, emoji TEXT NOT NULL, created_at INTEGER NOT NULL, @@ -179,10 +208,17 @@ function createTables(db: Database.Database): void { PRIMARY KEY (folder_id, space_id) ); + CREATE TABLE IF NOT EXISTS user_space_layout ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + layout TEXT NOT NULL DEFAULT '[]', + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS instance_settings ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), instance_name TEXT DEFAULT 'Backspace', worker_id INTEGER, + discovery_enabled INTEGER NOT NULL DEFAULT 1, max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000, min_bitrate_kbps INTEGER NOT NULL DEFAULT 500, bitrate_step_kbps INTEGER NOT NULL DEFAULT 500, @@ -190,8 +226,38 @@ function createTables(db: Database.Database): void { allowed_framerates TEXT NOT NULL DEFAULT '30,45,60', max_resolution INTEGER NOT NULL DEFAULT 1080, max_framerate INTEGER NOT NULL DEFAULT 60, + registration_open INTEGER, updated_at INTEGER NOT NULL DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS bans ( + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT, + banned_by TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (space_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS join_requests ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message TEXT, + status TEXT NOT NULL DEFAULT 'pending', + decided_by TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + decided_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS voice_restrictions ( + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + restriction_type TEXT NOT NULL, + moderator_id TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (space_id, user_id, restriction_type) + ); `); } diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 49b4137a..c2ae644d 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -138,6 +138,18 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'discoverable', type: 'INTEGER DEFAULT 1' }, ] + }, + { + name: 'attachments', + columns: [ + { name: 'uploader_id', type: 'TEXT' }, + ] + }, + { + name: 'users', + columns: [ + { name: 'password_changed_at', type: 'INTEGER' }, + ] } ]; @@ -175,7 +187,7 @@ export function runMigrations(db: Database.Database): void { space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, reason TEXT, - banned_by TEXT NOT NULL REFERENCES users(id), + banned_by TEXT REFERENCES users(id), created_at INTEGER NOT NULL, PRIMARY KEY (space_id, user_id) ); @@ -212,7 +224,7 @@ export function runMigrations(db: Database.Database): void { space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, restriction_type TEXT NOT NULL, - moderator_id TEXT NOT NULL REFERENCES users(id), + moderator_id TEXT REFERENCES users(id), created_at INTEGER NOT NULL, PRIMARY KEY (space_id, user_id, restriction_type) ); @@ -245,6 +257,9 @@ export function runMigrations(db: Database.Database): void { // ─── Free usernames from already-tombstoned users ─────────────────────────── migrateDeletedUsernames(db); + // ─── Fix nullable moderator columns (bans.banned_by, voice_restrictions.moderator_id) ─ + migrateNullableModeratorColumns(db); + // ─── Clean up orphaned data from deleted users and channels ──────────────── migrateOrphanedData(db); @@ -297,9 +312,99 @@ export function runMigrations(db: Database.Database): void { } } + // ─── Add FK constraint to dm_messages.reply_to_id ──────────────────────── + migrateDmMessagesReplyToFk(db); + + // ─── Add indexes on FK columns for query performance ───────────────────── + migrateAddIndexes(db); + console.log('Migrations complete.'); } +/** Add FK constraint to dm_messages.reply_to_id (SQLite requires table recreation) */ +function migrateDmMessagesReplyToFk(db: Database.Database): void { + const tableInfo = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='dm_messages'" + ).get() as { sql: string } | undefined; + + // Only migrate if reply_to_id exists but has no FK reference + if (!tableInfo) return; + if (!tableInfo.sql.includes('reply_to_id')) return; + if (tableInfo.sql.includes('REFERENCES dm_messages')) return; + + console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...'); + db.exec(` + CREATE TABLE dm_messages_new ( + id TEXT PRIMARY KEY, + dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL, + content TEXT, + edited_at INTEGER, + created_at INTEGER NOT NULL + ); + INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages; + DROP TABLE dm_messages; + ALTER TABLE dm_messages_new RENAME TO dm_messages; + CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id); + CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id); + `); +} + +/** Add database indexes on FK columns to prevent full table scans */ +function migrateAddIndexes(db: Database.Database): void { + // Fast-path: skip if indexes already exist + const existing = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_messages_channel_id'" + ).get(); + if (existing) return; + + console.log('Migrating: Adding database indexes...'); + + const indexes = [ + // Hot paths: message listing, channel sidebar + 'CREATE INDEX IF NOT EXISTS idx_messages_channel_id ON messages(channel_id)', + 'CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id)', + 'CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_channels_space_id ON channels(space_id)', + + // Member lookups & permission checks + 'CREATE INDEX IF NOT EXISTS idx_space_members_user_id ON space_members(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_member_roles_user_id_space_id ON member_roles(user_id, space_id)', + 'CREATE INDEX IF NOT EXISTS idx_roles_space_id ON roles(space_id)', + 'CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_id ON channel_overrides(channel_id)', + 'CREATE INDEX IF NOT EXISTS idx_dm_members_user_id ON dm_members(user_id)', + + // Reactions + 'CREATE INDEX IF NOT EXISTS idx_reactions_message_id ON reactions(message_id)', + 'CREATE INDEX IF NOT EXISTS idx_dm_reactions_dm_message_id ON dm_reactions(dm_message_id)', + + // Attachments + 'CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(message_id)', + 'CREATE INDEX IF NOT EXISTS idx_attachments_dm_message_id ON attachments(dm_message_id)', + + // Social + 'CREATE INDEX IF NOT EXISTS idx_friends_user_id ON friends(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_friends_friend_id ON friends(friend_id)', + 'CREATE INDEX IF NOT EXISTS idx_friend_requests_to_id ON friend_requests(to_id)', + 'CREATE INDEX IF NOT EXISTS idx_friend_requests_from_id ON friend_requests(from_id)', + + // Moderation & discovery + 'CREATE INDEX IF NOT EXISTS idx_bans_space_id ON bans(space_id)', + 'CREATE INDEX IF NOT EXISTS idx_join_requests_space_id_status ON join_requests(space_id, status)', + 'CREATE INDEX IF NOT EXISTS idx_voice_restrictions_space_id ON voice_restrictions(space_id)', + + // Read states + 'CREATE INDEX IF NOT EXISTS idx_read_states_user_id ON read_states(user_id)', + + // Categories + 'CREATE INDEX IF NOT EXISTS idx_channel_categories_space_id ON channel_categories(space_id)', + ]; + + db.exec(indexes.join(';\n')); +} + /** Convert legacy JSON array permissions (e.g. '["VIEW_CHANNEL"]') to decimal strings */ function migrateLegacyPermissions(db: Database.Database): void { const roles = db.prepare('SELECT id, permissions FROM roles WHERE permissions IS NOT NULL').all() as { id: string; permissions: string }[]; @@ -527,6 +632,63 @@ function migrateDeletedUsernames(db: Database.Database): void { } } +/** + * Fix DDL for bans and voice_restrictions tables: make banned_by and moderator_id nullable. + * The original CREATE TABLE statements used NOT NULL, but these columns must be nullable + * to handle cases where the moderator account is later deleted. + */ +function migrateNullableModeratorColumns(db: Database.Database): void { + // Fix bans.banned_by: NOT NULL → nullable + { + const tableInfo = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='bans'" + ).get() as { sql: string } | undefined; + + if (tableInfo && tableInfo.sql.includes('banned_by TEXT NOT NULL')) { + console.log('Migrating: Making bans.banned_by nullable...'); + db.exec(` + CREATE TABLE bans_new ( + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT, + banned_by TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (space_id, user_id) + ); + INSERT INTO bans_new SELECT space_id, user_id, reason, banned_by, created_at FROM bans; + DROP TABLE bans; + ALTER TABLE bans_new RENAME TO bans; + CREATE INDEX IF NOT EXISTS idx_bans_space_id ON bans(space_id); + `); + } + } + + // Fix voice_restrictions.moderator_id: NOT NULL → nullable + { + const tableInfo = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='voice_restrictions'" + ).get() as { sql: string } | undefined; + + if (tableInfo && tableInfo.sql.includes('moderator_id TEXT NOT NULL')) { + console.log('Migrating: Making voice_restrictions.moderator_id nullable...'); + db.exec(` + CREATE TABLE voice_restrictions_new ( + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + restriction_type TEXT NOT NULL, + moderator_id TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (space_id, user_id, restriction_type) + ); + INSERT INTO voice_restrictions_new SELECT space_id, user_id, restriction_type, moderator_id, created_at FROM voice_restrictions; + DROP TABLE voice_restrictions; + ALTER TABLE voice_restrictions_new RENAME TO voice_restrictions; + CREATE INDEX IF NOT EXISTS idx_voice_restrictions_space_id ON voice_restrictions(space_id); + `); + } + } +} + /** * Clean up orphaned data left behind by user deletions and channel removals: * 1. DM channels with zero members diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 2a689994..206bb9dc 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -19,6 +19,7 @@ export const users = sqliteTable('users', { isDeleted: integer('is_deleted').default(0), discoverable: integer('discoverable').default(1), profileUpdatedAt: integer('profile_updated_at'), + passwordChangedAt: integer('password_changed_at'), createdAt: integer('created_at').notNull(), }); @@ -82,6 +83,7 @@ export const attachments = sqliteTable('attachments', { id: text('id').primaryKey(), messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }), dmMessageId: text('dm_message_id').references(() => dmMessages.id, { onDelete: 'cascade' }), + uploaderId: text('uploader_id'), filename: text('filename').notNull(), originalName: text('original_name').notNull(), mimetype: text('mimetype').notNull(), @@ -112,7 +114,12 @@ export const dmMessages = sqliteTable('dm_messages', { content: text('content'), editedAt: integer('edited_at'), createdAt: integer('created_at').notNull(), -}); +}, (table) => ({ + replyToFk: foreignKey({ + columns: [table.replyToId], + foreignColumns: [table.id], + }).onDelete('set null'), +})); export const friends = sqliteTable('friends', { userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0e046816..39ed5ca1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -29,6 +29,7 @@ import fs from 'fs'; async function main(): Promise { const app = Fastify({ + trustProxy: true, logger: { level: 'info', }, diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 169afcba..75f4895e 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -188,7 +188,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { const hash = await hashPassword(temporaryPassword); db.update(schema.users) - .set({ passwordHash: hash }) + .set({ passwordHash: hash, passwordChangedAt: Date.now() }) .where(eq(schema.users.id, targetId)) .run(); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 7978ae04..f9a7ab36 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -68,8 +68,8 @@ export async function authRoutes(app: FastifyInstance): Promise { } } - if (password.length < 6) { - return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 }); + if (password.length < 8) { + return reply.code(400).send({ error: 'Password must be at least 8 characters', statusCode: 400 }); } const db = getDb(); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index f02f57cf..e25f0d49 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -1,20 +1,21 @@ import type { FastifyInstance } from 'fastify'; -import { eq, and, or, desc, lt, inArray } from 'drizzle-orm'; +import { eq, and, or, desc, lt, inArray, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { isDmMember } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; -import type { - DmChannel, - DmMessage, - DmMessageWithUser, - CreateDmRequest, - CreateDmMessageRequest, - AddDmMemberRequest, - PaginatedQuery, - Attachment, - Reaction, +import { + MAX_MESSAGE_LENGTH, + type DmChannel, + type DmMessage, + type DmMessageWithUser, + type CreateDmRequest, + type CreateDmMessageRequest, + type AddDmMemberRequest, + type PaginatedQuery, + type Attachment, + type Reaction, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js'; @@ -210,47 +211,90 @@ export async function dmRoutes(app: FastifyInstance): Promise { )) .all(); - const dmChannels: DmChannel[] = []; + if (memberships.length === 0) { + return reply.code(200).send([]); + } - for (const membership of memberships) { - const dmChannel = db.select() - .from(schema.dmChannels) - .where(eq(schema.dmChannels.id, membership.dmChannelId)) - .get(); + const dmChannelIds = memberships.map(m => m.dmChannelId); - if (!dmChannel) continue; + // Batch fetch all DM channels + const channelRows = db.select().from(schema.dmChannels) + .where(inArray(schema.dmChannels.id, dmChannelIds)).all(); + const channelMap = new Map(channelRows.map(c => [c.id, c])); - const dmMemberRows = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId)) - .all(); + // Batch fetch all DM members + const allMemberRows = db.select().from(schema.dmMembers) + .where(inArray(schema.dmMembers.dmChannelId, dmChannelIds)).all(); + const membersByChannel = new Map(); + for (const m of allMemberRows) { + if (!membersByChannel.has(m.dmChannelId)) membersByChannel.set(m.dmChannelId, []); + membersByChannel.get(m.dmChannelId)!.push(m.userId); + } - const memberUserIds = dmMemberRows.map(m => m.userId); - const users = memberUserIds.length > 0 - ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() - : []; + // Batch fetch all unique users + const allUserIds = [...new Set(allMemberRows.map(m => m.userId))]; + const userRows = allUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all() + : []; + const userMap = new Map(userRows.map(u => [u.id, u])); - // Get last message - const allMessages = db.select() + // Batch fetch last message per DM channel: + // Get the max created_at per channel, then fetch matching messages + const maxTimestamps = db.select({ + dmChannelId: schema.dmMessages.dmChannelId, + maxCreatedAt: sql`MAX(${schema.dmMessages.createdAt})`.as('max_created_at'), + }) + .from(schema.dmMessages) + .where(inArray(schema.dmMessages.dmChannelId, dmChannelIds)) + .groupBy(schema.dmMessages.dmChannelId) + .all(); + + const lastMessageMap = new Map(); + if (maxTimestamps.length > 0) { + // Build conditions to fetch the actual message rows matching max timestamps + const conditions = maxTimestamps.map(t => + and(eq(schema.dmMessages.dmChannelId, t.dmChannelId), eq(schema.dmMessages.createdAt, t.maxCreatedAt!)) + ); + const lastMessages = db.select() .from(schema.dmMessages) - .where(eq(schema.dmMessages.dmChannelId, membership.dmChannelId)) - .orderBy(desc(schema.dmMessages.createdAt)) - .limit(1) + .where(or(...conditions)) .all(); + for (const m of lastMessages) { + // In case of ties, keep the first one per channel + if (!lastMessageMap.has(m.dmChannelId)) { + lastMessageMap.set(m.dmChannelId, { + id: m.id, dmChannelId: m.dmChannelId, userId: m.userId, + content: m.content, createdAt: m.createdAt, + }); + } + } + } - const lastMessage = allMessages[0] ?? null; + // Assemble results + const dmChannels: DmChannel[] = []; + for (const channelId of dmChannelIds) { + const channel = channelMap.get(channelId); + if (!channel) continue; + + const memberIds = membersByChannel.get(channelId) ?? []; + const members = memberIds + .map(id => userMap.get(id)) + .filter((u): u is NonNullable => u !== undefined) + .map(sanitizeUser); + + const lastMsg = lastMessageMap.get(channelId) ?? null; dmChannels.push({ - id: dmChannel.id, - ownerId: dmChannel.ownerId ?? null, - createdAt: dmChannel.createdAt, - members: users.map(sanitizeUser), - lastMessage: lastMessage ? { - id: lastMessage.id, - dmChannelId: lastMessage.dmChannelId, - userId: lastMessage.userId, - content: lastMessage.content, - createdAt: lastMessage.createdAt, + id: channel.id, + ownerId: channel.ownerId ?? null, + createdAt: channel.createdAt, + members, + lastMessage: lastMsg ? { + id: lastMsg.id, + dmChannelId: lastMsg.dmChannelId, + userId: lastMsg.userId, + content: lastMsg.content, + createdAt: lastMsg.createdAt, } : null, }); } @@ -848,10 +892,27 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 }); } + if (content && content.length > MAX_MESSAGE_LENGTH) { + return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 }); + } + const db = getDb(); const messageId = generateSnowflake(); const now = Date.now(); + // Verify attachment ownership before linking + if (attachmentIds && attachmentIds.length > 0) { + for (const attId of attachmentIds) { + const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get(); + if (!att || att.messageId || att.dmMessageId) { + return reply.code(400).send({ error: 'Invalid or already-used attachment', statusCode: 400 }); + } + if (att.uploaderId && att.uploaderId !== request.userId) { + return reply.code(400).send({ error: 'You do not own this attachment', statusCode: 400 }); + } + } + } + // Insert message and link attachments atomically db.transaction((tx) => { tx.insert(schema.dmMessages).values({ @@ -895,6 +956,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Message content is required', statusCode: 400 }); } + if (content.length > MAX_MESSAGE_LENGTH) { + return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 }); + } + const db = getDb(); const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get(); diff --git a/packages/server/src/routes/explore.ts b/packages/server/src/routes/explore.ts index a031b123..4a7180d9 100644 --- a/packages/server/src/routes/explore.ts +++ b/packages/server/src/routes/explore.ts @@ -3,7 +3,7 @@ import { eq, and, sql, inArray } from 'drizzle-orm'; import { getDb, getRawDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; -import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; +import { isMember, isBanned, isSpaceOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; import { sanitizeUser } from '../utils/sanitize.js'; import type { @@ -282,6 +282,10 @@ export async function exploreRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'This space does not allow public joins', statusCode: 403 }); } + if (isBanned(id, request.userId)) { + return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 }); + } + if (isMember(id, request.userId)) { return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 }); } @@ -345,6 +349,10 @@ export async function exploreRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'This space does not accept join requests', statusCode: 403 }); } + if (isBanned(id, request.userId)) { + return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 }); + } + if (isMember(id, request.userId)) { return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 }); } @@ -410,6 +418,9 @@ export async function exploreRoutes(app: FastifyInstance): Promise { } const statusFilter = request.query.status ?? 'pending'; + if (!['pending', 'accepted', 'declined'].includes(statusFilter)) { + return reply.code(400).send({ error: 'Status must be one of: pending, accepted, declined', statusCode: 400 }); + } const rows = db.select().from(schema.joinRequests) .where(and( eq(schema.joinRequests.spaceId, id), diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index 3b01c430..f176fd2c 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -5,12 +5,13 @@ import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { hasPermission, getChannelSpaceId, PermissionBits } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; -import type { - CreateMessageRequest, - UpdateMessageRequest, - PaginatedQuery, - MessageWithUser, - Reaction, +import { + MAX_MESSAGE_LENGTH, + type CreateMessageRequest, + type UpdateMessageRequest, + type PaginatedQuery, + type MessageWithUser, + type Reaction, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; @@ -272,10 +273,28 @@ export async function messageRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 }); } + if (content && content.length > MAX_MESSAGE_LENGTH) { + return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 }); + } + const db = getDb(); const messageId = generateSnowflake(); const now = Date.now(); + // Verify attachment ownership before linking + if (attachmentIds && attachmentIds.length > 0) { + for (const attId of attachmentIds) { + const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get(); + if (!att || att.messageId || att.dmMessageId) { + return reply.code(400).send({ error: 'Invalid or already-used attachment', statusCode: 400 }); + } + // Skip ownership check for legacy uploads (null uploaderId) + if (att.uploaderId && att.uploaderId !== request.userId) { + return reply.code(400).send({ error: 'You do not own this attachment', statusCode: 400 }); + } + } + } + // Insert message and link attachments atomically db.transaction((tx) => { tx.insert(schema.messages).values({ @@ -341,6 +360,10 @@ export async function messageRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Content is required', statusCode: 400 }); } + if (content.length > MAX_MESSAGE_LENGTH) { + return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 }); + } + const db = getDb(); const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); if (!message) { diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index e13ae3f2..43ede34f 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq, and, or, ne, like, sql } from 'drizzle-orm'; +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'; @@ -284,6 +284,16 @@ export async function socialRoutes(app: FastifyInstance): Promise { 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)) @@ -301,6 +311,7 @@ export async function socialRoutes(app: FastifyInstance): Promise { // 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() || ''; @@ -368,23 +379,58 @@ export async function socialRoutes(app: FastifyInstance): Promise { .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> for page users + const friendIdsByUser = new Map>(); + 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> for page users + const spaceIdsByUser = new Map>(); + 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 - const theirFriendRows = db.select().from(schema.friends).where( - or(eq(schema.friends.userId, row.id), eq(schema.friends.friendId, row.id)) - ).all(); - const theirFriendIds = new Set(theirFriendRows.map(f => f.userId === row.id ? f.friendId : f.userId)); + // 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 - const theirSpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId }) - .from(schema.spaceMembers) - .where(eq(schema.spaceMembers.userId, row.id)) - .all(); - const theirSpaceIds = new Set(theirSpaceRows.map(s => s.spaceId)); + // Mutual spaces (using batch-fetched data) + const theirSpaceIds = spaceIdsByUser.get(row.id) ?? new Set(); const mutualSpaceCount = [...mySpaceIds].filter(id => theirSpaceIds.has(id)).length; // Relationship @@ -432,6 +478,7 @@ export async function socialRoutes(app: FastifyInstance): Promise { // 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(); diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index f9be826f..c3c504ab 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -7,6 +7,7 @@ import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, Pe import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; import crypto from 'crypto'; import { connectionManager } from '../ws/handler.js'; +import { deleteAttachmentFiles, deleteUploadFile } from '../utils/fileCleanup.js'; import type { CreateSpaceRequest, UpdateSpaceRequest, @@ -367,6 +368,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise { updates.name = trimmedName; } + // Track old files for cleanup after update + const oldIcon = server.icon; + const oldBanner = server.banner; + if (icon !== undefined) { updates.icon = icon || null; } @@ -404,6 +409,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise { db.update(schema.spaces).set(updates).where(eq(schema.spaces.id, id)).run(); + // Clean up old icon/banner files that were replaced + if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) { + deleteUploadFile(oldIcon); + } + if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) { + deleteUploadFile(oldBanner); + } + const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!updated) { return reply.code(500).send({ error: 'Failed to update space', statusCode: 500 }); @@ -436,6 +449,24 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 }); } + // Collect all attachment files before cascade-deleting DB records + const channelIds = db.select({ id: schema.channels.id }) + .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id); + + let attachmentRows: { filename: string }[] = []; + if (channelIds.length > 0) { + const messageIds = db.select({ id: schema.messages.id }) + .from(schema.messages).where(inArray(schema.messages.channelId, channelIds)).all().map(m => m.id); + if (messageIds.length > 0) { + attachmentRows = db.select({ filename: schema.attachments.filename }) + .from(schema.attachments).where(inArray(schema.attachments.messageId, messageIds)).all(); + } + } + + // Capture space icon/banner before deletion + const spaceIcon = server.icon; + const spaceBanner = server.banner; + // Delete all channels (messages cascade), members, folder refs, then space atomically db.transaction((tx) => { tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run(); @@ -444,6 +475,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise { tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run(); }); + // Clean up all attachment files from disk + deleteAttachmentFiles(attachmentRows); + + // Clean up space icon/banner files + if (spaceIcon && !spaceIcon.startsWith('http')) deleteUploadFile(spaceIcon); + if (spaceBanner && !spaceBanner.startsWith('http')) deleteUploadFile(spaceBanner); + return reply.code(200).send({ success: true }); }); diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts index 1d10b28b..45ecdd89 100644 --- a/packages/server/src/routes/uploads.ts +++ b/packages/server/src/routes/uploads.ts @@ -78,6 +78,7 @@ export async function uploadRoutes(app: FastifyInstance): Promise { // Save attachment record db.insert(schema.attachments).values({ id, + uploaderId: request.userId, filename, originalName, mimetype, @@ -120,12 +121,16 @@ export async function uploadRoutes(app: FastifyInstance): Promise { ?? EXT_MIMETYPES[path.extname(safeName).toLowerCase()] ?? 'application/octet-stream'; - // Set caching headers + // Set caching and security headers reply.header('Cache-Control', 'public, max-age=31536000, immutable'); reply.header('Content-Type', mimetype); + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'"); + reply.header('X-Frame-Options', 'DENY'); - // For non-image files, set Content-Disposition to download - if (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/')) { + // For non-media files and SVGs, force download instead of inline rendering + const isSvg = mimetype === 'image/svg+xml'; + if (isSvg || (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/'))) { reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`); } diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 43010aa8..535c4f54 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -10,6 +10,15 @@ import { deleteUploadFile } from '../utils/fileCleanup.js'; import { tombstoneUser } from '../utils/userDeletion.js'; import { generateSnowflake } from '../utils/snowflake.js'; +/** Validates that a URL is a safe asset URL (relative upload path 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; + return false; +} + export async function userRoutes(app: FastifyInstance): Promise { app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { const db = getDb(); @@ -48,8 +57,8 @@ export async function userRoutes(app: FastifyInstance): Promise { }, async (request, reply) => { const { currentPassword, newPassword } = request.body; - if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) { - return reply.code(400).send({ error: 'New password must be at least 6 characters', statusCode: 400 }); + 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(); @@ -58,20 +67,17 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } - // Native users (no homeInstance) must provide current password - if (!user.homeInstance) { - 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 }); - } + // All users must provide current password (federated users have a local password hash) + 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 }); } - // Federated users: JWT auth is sufficient — skip old password verification const newHash = await hashPassword(newPassword); - db.update(schema.users).set({ passwordHash: newHash }).where(eq(schema.users.id, request.userId)).run(); + 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 }); @@ -157,11 +163,27 @@ export async function userRoutes(app: FastifyInstance): Promise { } } + // 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; } 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(); } else { @@ -228,17 +250,36 @@ export async function userRoutes(app: FastifyInstance): Promise { if (!Array.isArray(replicatedInstances)) { return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 }); } - // Validate each entry has (origin or domain) and username strings + 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') { - return reply.code(400).send({ error: 'Each replicated instance must have username string', statusCode: 400 }); + 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 (replicatedInstances.length > 50) { - return reply.code(400).send({ error: 'Maximum 50 replicated instances', 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); } @@ -286,6 +327,14 @@ export async function userRoutes(app: FastifyInstance): Promise { db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run(); + // Clean up old avatar/banner files that were replaced + if (avatar !== undefined && oldAvatar && oldAvatar !== (avatar || null) && !oldAvatar.startsWith('http')) { + deleteUploadFile(oldAvatar); + } + if (banner !== undefined && oldBanner && oldBanner !== (updateData.banner ?? null) && !oldBanner.startsWith('http')) { + deleteUploadFile(oldBanner); + } + 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 }); diff --git a/packages/server/src/routes/utils.ts b/packages/server/src/routes/utils.ts index 469e4fd6..196e8116 100644 --- a/packages/server/src/routes/utils.ts +++ b/packages/server/src/routes/utils.ts @@ -1,7 +1,23 @@ import type { FastifyInstance } from 'fastify'; +import dns from 'dns'; import { authenticate } from '../utils/auth.js'; import * as cheerio from 'cheerio'; +function isPrivateIp(ip: string): boolean { + // IPv4 + if (ip.startsWith('127.') || ip.startsWith('0.') || ip === '0.0.0.0') return true; + if (ip.startsWith('10.')) return true; + if (ip.startsWith('192.168.')) return true; + if (ip.startsWith('169.254.')) return true; + if (ip.startsWith('172.')) { + const second = parseInt(ip.split('.')[1] ?? '', 10); + if (second >= 16 && second <= 31) return true; + } + // IPv6 + if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true; + return false; +} + export async function utilRoutes(app: FastifyInstance): Promise { app.get<{ Querystring: { url: string } }>('/api/utils/metadata', { preHandler: authenticate, @@ -12,19 +28,57 @@ export async function utilRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'URL is required' }); } + // Validate URL scheme + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return reply.code(400).send({ error: 'Invalid URL' }); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return reply.code(400).send({ error: 'Only HTTP(S) URLs are supported' }); + } + + // Resolve hostname and block private/internal IPs + let address: string; + try { + const result = await dns.promises.lookup(parsed.hostname); + address = result.address; + } catch { + return reply.code(200).send({}); + } + + if (isPrivateIp(address)) { + return reply.code(400).send({ error: 'URLs pointing to private/internal addresses are not allowed' }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); try { const response = await fetch(url, { headers: { 'User-Agent': 'BackspaceBot/1.0', }, + signal: controller.signal, + redirect: 'follow', }); + clearTimeout(timeout); if (!response.ok) { - throw new Error('Failed to fetch URL'); + return reply.code(200).send({}); + } + + // Reject oversized responses + const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10); + if (contentLength > 512_000) { + return reply.code(200).send({}); } const html = await response.text(); - const $ = cheerio.load(html); + const safeHtml = html.length > 512_000 ? html.slice(0, 512_000) : html; + + const $ = cheerio.load(safeHtml); const metadata = { title: $('meta[property="og:title"]').attr('content') || $('title').text(), @@ -37,6 +91,8 @@ export async function utilRoutes(app: FastifyInstance): Promise { return reply.code(200).send(metadata); } catch (err) { return reply.code(200).send({}); // Fail silently with empty object + } finally { + clearTimeout(timeout); } }); } diff --git a/packages/server/src/utils/auth.ts b/packages/server/src/utils/auth.ts index dbe4be4e..0a17cdd8 100644 --- a/packages/server/src/utils/auth.ts +++ b/packages/server/src/utils/auth.ts @@ -18,6 +18,7 @@ export async function verifyPassword(password: string, hash: string): Promise = new Map(); export function handleClientEvent( @@ -216,6 +218,11 @@ function handleMessageCreate(event: Record, userId: string): vo return; } + if (content.length > MAX_MESSAGE_LENGTH) { + connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` }); + return; + } + const spaceId = getChannelSpaceId(channelId); if (!spaceId) { connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' }); @@ -264,6 +271,11 @@ function handleMessageEdit(event: Record, userId: string): void return; } + if (content.length > MAX_MESSAGE_LENGTH) { + connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` }); + return; + } + const db = getDb(); const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); if (!message) { @@ -321,9 +333,18 @@ function handleMessageDelete(event: Record, userId: string): vo return; } - // Delete attachments then message - db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run(); - db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run(); + // Collect attachment filenames before deletion + const attachmentRows = db.select({ filename: schema.attachments.filename }) + .from(schema.attachments).where(eq(schema.attachments.messageId, messageId)).all(); + + // Delete attachments + message atomically, file cleanup outside transaction + db.transaction((tx) => { + tx.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run(); + tx.delete(schema.messages).where(eq(schema.messages.id, messageId)).run(); + }); + + // Clean up files from disk (outside transaction — file I/O) + deleteAttachmentFiles(attachmentRows); connectionManager.sendToChannel(spaceId, message.channelId, { type: 'message_deleted', @@ -357,6 +378,9 @@ function handleTypingStart(event: Record, userId: string, usern username, }, userId); + // Safety cap: skip if Map is at max capacity (auto-expiry handles normal cleanup) + if (!existing && typingTimeouts.size >= MAX_TYPING_ENTRIES) return; + // Auto-expire typing after 5 seconds const timeout = setTimeout(() => { typingTimeouts.delete(key); @@ -674,12 +698,33 @@ function handleDmMessageCreate(event: Record, userId: string): return; } + if (hasContent && content!.length > MAX_MESSAGE_LENGTH) { + connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` }); + return; + } + if (!isDmMember(dmChannelId, userId)) { connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' }); return; } const db = getDb(); + + // Verify attachment ownership before linking + if (hasAttachments) { + for (const attId of attachmentIds) { + const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get(); + if (!att || att.messageId || att.dmMessageId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Invalid or already-used attachment' }); + return; + } + if (att.uploaderId && att.uploaderId !== userId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You do not own this attachment' }); + return; + } + } + } + const messageId = generateSnowflake(); const now = Date.now(); @@ -760,6 +805,11 @@ function handleDmMessageEdit(event: Record, userId: string): vo return; } + if (content.length > MAX_MESSAGE_LENGTH) { + connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` }); + return; + } + const db = getDb(); const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get(); if (!msg) { @@ -814,6 +864,10 @@ function handleDmMessageDelete(event: Record, userId: string): return; } + // Collect attachment filenames before deletion + const dmAttachmentRows = db.select({ filename: schema.attachments.filename }) + .from(schema.attachments).where(eq(schema.attachments.dmMessageId, messageId)).all(); + // Delete attachments linked to this DM message db.delete(schema.attachments) .where(eq(schema.attachments.dmMessageId, messageId)) @@ -829,6 +883,9 @@ function handleDmMessageDelete(event: Record, userId: string): .where(eq(schema.dmMessages.id, messageId)) .run(); + // Clean up files from disk + deleteAttachmentFiles(dmAttachmentRows); + const dmMembers = db.select() .from(schema.dmMembers) .where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId)) diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index fcbc496d..3b096da0 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -89,6 +89,8 @@ class ConnectionManager { private spaceDeafenedUsers: Set = new Set(); // Stores spaceId:userId // Permission-muted users (SPEAK permission revoked while in voice) private permissionMutedUsers: Set = new Set(); // Stores spaceId:userId + // Per-user WebSocket rate limiters (shared across all tabs/connections) + private userRateLimiters: Map = new Map(); addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -203,6 +205,9 @@ class ConnectionManager { // Clean up userSpaces (re-populated on next connect via setUserSpaces) this.userSpaces.delete(userId); + + // Clean up per-user rate limiter + this.userRateLimiters.delete(userId); } getUserConnections(userId: string): Set { @@ -214,6 +219,15 @@ class ConnectionManager { return conns !== undefined && conns.size > 0; } + getUserRateLimiter(userId: string): WsRateLimiter { + let limiter = this.userRateLimiters.get(userId); + if (!limiter) { + limiter = new WsRateLimiter(); + this.userRateLimiters.set(userId, limiter); + } + return limiter; + } + setUserSpaces(userId: string, spaceIds: string[]): void { this.userSpaces.set(userId, new Set(spaceIds)); } @@ -1068,7 +1082,6 @@ export async function registerWebSocket(app: FastifyInstance): Promise { let authenticated = false; let userId: string | undefined; let username: string | undefined; - const rateLimiter = new WsRateLimiter(); // Set auth timeout - must authenticate within 10 seconds const authTimeout = setTimeout(() => { @@ -1104,7 +1117,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise { userId = payload.userId; username = payload.username; - // Reject deleted users + // Reject deleted users and revoked tokens const db = getDb(); const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); if (!userRow || userRow.isDeleted) { @@ -1112,6 +1125,14 @@ export async function registerWebSocket(app: FastifyInstance): Promise { ws.close(); return; } + // Token revocation: reject tokens issued before last password change + if (userRow.passwordChangedAt && payload.iat) { + if (payload.iat < Math.floor(userRow.passwordChangedAt / 1000)) { + ws.send(JSON.stringify({ type: 'error', message: 'Token has been revoked' })); + ws.close(); + return; + } + } authenticated = true; clearTimeout(authTimeout); @@ -1155,15 +1176,22 @@ export async function registerWebSocket(app: FastifyInstance): Promise { return; } - // Rate limit all post-auth, non-ping messages - if (!rateLimiter.consume()) { + // Rate limit all post-auth, non-ping messages (per-user, shared across tabs) + if (!connectionManager.getUserRateLimiter(userId!).consume()) { ws.send(JSON.stringify({ type: 'error', message: 'Rate limited' })); return; } // Handle authenticated events if (userId && username) { - handleClientEvent(parsed, userId, username); + try { + handleClientEvent(parsed, userId, username); + } catch (err) { + app.log.error({ err, eventType: parsed.type, userId }, 'Unhandled error in WS event handler'); + try { + ws.send(JSON.stringify({ type: 'error', message: 'Internal server error' })); + } catch { /* ws may already be closed */ } + } } }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 12cd6fb6..25e7f84a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,3 +1,7 @@ +// ─── Constants ────────────────────────────────────────────────────────────── + +export const MAX_MESSAGE_LENGTH = 4000; + // ─── User Types ───────────────────────────────────────────────────────────── export const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const; diff --git a/packages/web/src/components/chat/Embed.tsx b/packages/web/src/components/chat/Embed.tsx index 2645b823..79628231 100644 --- a/packages/web/src/components/chat/Embed.tsx +++ b/packages/web/src/components/chat/Embed.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { api } from '../../api/client'; +import { useAuthStore } from '../../stores/authStore'; interface EmbedProps { url: string; @@ -17,26 +17,28 @@ export function Embed({ url }: EmbedProps) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { - let isMounted = true; - - // Simple fetch from our new API + const controller = new AbortController(); + + const token = useAuthStore.getState().token; fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, { headers: { - 'Authorization': `Bearer ${localStorage.getItem('backspace_token')}` - } + 'Authorization': `Bearer ${token}` + }, + signal: controller.signal, }) .then(res => res.json()) .then(data => { - if (isMounted && data.title) { + if (data.title) { setMetadata(data); } setIsLoading(false); }) - .catch(() => { - if (isMounted) setIsLoading(false); + .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; + setIsLoading(false); }); - return () => { isMounted = false; }; + return () => { controller.abort(); }; }, [url]); if (isLoading || !metadata) return null; @@ -50,9 +52,9 @@ export function Embed({ url }: EmbedProps) { )} {metadata.title && ( - @@ -67,9 +69,9 @@ export function Embed({ url }: EmbedProps) { {metadata.image && (
-
diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 9e9492de..00567a09 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -54,10 +54,10 @@ export function FriendsPage() { } }; - const handleOpenDm = async (friendId: string, instanceOrigin: string) => { + const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => { try { // Check if a DM already exists with this user (on any instance) - const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId }); + const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId, homeUserId: homeUserId ?? undefined }); if (existing) { useUIStore.getState().setShowDms(true); navigate(`/channels/@me/${existing.dm.id}`); @@ -99,7 +99,7 @@ export function FriendsPage() { ) : ( onlineFriends.map(friend => ( - removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} /> + removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} /> )) )} @@ -116,7 +116,7 @@ export function FriendsPage() { ) : ( friends.map(friend => ( - removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} /> + removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} /> )) )} @@ -227,7 +227,7 @@ function AddFriendTab({ addStatus: { type: 'success' | 'error'; message: string } | null; isLoading: boolean; onSubmit: (e: React.FormEvent) => void; - onOpenDm: (userId: string, origin: string) => void; + onOpenDm: (userId: string, origin: string, homeUserId?: string) => void; }) { const discoverUsers = useDiscoverStore((s) => s.users); const discoverLoading = useDiscoverStore((s) => s.isLoading); @@ -354,7 +354,7 @@ function UserDiscoverCard({ onOpenDm, }: { user: TaggedDiscoverUser; - onOpenDm: (userId: string, origin: string) => void; + onOpenDm: (userId: string, origin: string, homeUserId?: string) => void; }) { const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest); @@ -443,7 +443,7 @@ function UserDiscoverCard({ }; const handleMessage = () => { - onOpenDm(user.id, user._instanceOrigin); + onOpenDm(user.id, user._instanceOrigin, user.homeUserId ?? undefined); }; return ( diff --git a/packages/web/src/components/chat/MarkdownRenderer.tsx b/packages/web/src/components/chat/MarkdownRenderer.tsx index 16161a5a..73f94274 100644 --- a/packages/web/src/components/chat/MarkdownRenderer.tsx +++ b/packages/web/src/components/chat/MarkdownRenderer.tsx @@ -192,6 +192,8 @@ function buildComponents(): Components { alt={alt ?? ''} className="max-w-full max-h-[350px] rounded-md mt-1" loading="lazy" + referrerPolicy="no-referrer" + crossOrigin="anonymous" /> ), diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 5dce641d..d7b20529 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -5,7 +5,7 @@ import { wsSend } from '../../hooks/useWebSocket'; import { MentionPopover } from './MentionPopover'; import { TypingIndicator } from './TypingIndicator'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; -import type { MemberWithUser } from '@backspace/shared'; +import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared'; interface MessageInputProps { channelId: string; @@ -76,9 +76,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { }, 3000); }, [channelId]); + const remaining = MAX_MESSAGE_LENGTH - content.length; + const isOverLimit = remaining < 0; + const handleSubmit = async () => { const trimmed = content.trim(); if (!trimmed && files.length === 0) return; + if (isOverLimit) return; setIsUploading(true); setMentionState(null); @@ -364,6 +368,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { )} + {/* Character counter (shows when near or over limit) */} + {content.length > MAX_MESSAGE_LENGTH - 200 && ( + + {remaining} + + )} + {/* GIF button */} @@ -98,10 +101,10 @@ export function IncomingCallModal() { {/* Accept */} diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index c6e9caba..85940d87 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -105,7 +105,7 @@ export function VoiceControlBar() { const handleDisconnect = () => { const { activeDmCall } = useVoiceStore.getState(); if (activeDmCall) { - wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only + wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); useVoiceStore.getState().setActiveDmCall(null); } else { wsSend({ type: 'voice_leave' }, voiceOrigin); diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 9df07d7d..046fc922 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -76,7 +76,7 @@ export function VoiceControls() { const handleDisconnect = () => { const { activeDmCall } = useVoiceStore.getState(); if (activeDmCall) { - wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only + wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); useVoiceStore.getState().setActiveDmCall(null); } else { wsSend({ type: 'voice_leave' }, voiceOrigin); diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index bb21bd1b..05ee5117 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -387,7 +387,7 @@ export function useLiveKit() { } try { - const client = isDm ? getApiForOrigin('') : getApiForOrigin(getChannelOrigin(channelId)); + const client = getApiForOrigin(getChannelOrigin(channelId)); const { token, url } = isDm ? await client.livekit.dmToken(channelId) : await client.livekit.token(channelId); if (gen !== _connectGeneration) return; const newRoom = new Room({ adaptiveStream: true, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: true } }); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index bf081961..f1589d40 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -238,8 +238,8 @@ function handleEvent(origin: string, event: ServerEvent): void { } } - // Restore DM call state from server (home only) - if (isHome) { + // Restore DM call state from server (all origins — federated DMs live on remote instances) + { const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall } = useVoiceStore.getState(); const myId = event.user.id; if (event.activeCalls && event.activeCalls.length > 0) { @@ -521,10 +521,9 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } - // ─── DM call events (home-only) ───────────────────────────────────────── + // ─── DM call events (all origins) ────────────────────────────────────── case 'dm_call_incoming': { - if (!isHome) break; const { setIncomingCall } = useVoiceStore.getState(); setIncomingCall({ dmChannelId: event.dmChannelId, @@ -535,7 +534,6 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_accepted': { - if (!isHome) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -544,7 +542,6 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_rejected': { - if (!isHome) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -553,7 +550,6 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_ended': { - if (!isHome) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index c68d9d56..c1bc211f 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -595,14 +595,16 @@ export const useChatStore = create((set, get) => ({ }); }, - updateUserInMessages: (user: { id: string; [key: string]: any }) => { + updateUserInMessages: (user: { id: string; homeUserId?: string | null; [key: string]: any }) => { set((state) => { const newMessages = new Map(state.messages); let changed = false; for (const [channelId, msgs] of newMessages) { let channelChanged = false; const updated = msgs.map(m => { - if (m.userId === user.id) { + const matches = m.userId === user.id || + (user.homeUserId && m.user?.homeUserId && m.user.homeUserId === user.homeUserId); + if (matches) { channelChanged = true; return { ...m, user: { ...m.user, ...user } }; } diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 0ddbc94a..8790fe52 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -242,8 +242,9 @@ export const useSocialStore = create((set, get) => ({ if (result.status !== 'fulfilled') return; const origin = searches[i]!.origin; for (const user of result.value) { - if (seen.has(user.id)) continue; - seen.add(user.id); + const dedupeKey = `${origin ?? ''}:${user.id}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); if (origin) normalizeUserAssets(user, origin); allUsers.push(user); } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 4210761a..806cdd8f 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -527,7 +527,7 @@ export const useVoiceStore = create()( }), { name: 'backspace-voice-settings', - version: 8, + version: 9, migrate: (persistedState: any, version: number) => { if (version === 0) { persistedState.streamAttenuationEnabled = false; @@ -563,6 +563,9 @@ export const useVoiceStore = create()( if (version < 8) { persistedState.soundEffectVolume = 100; } + if (version < 9) { + delete persistedState.currentVoiceChannelId; + } return persistedState; }, storage: createJSONStorage(() => localStorage), @@ -570,7 +573,6 @@ export const useVoiceStore = create()( // noiseSuppression is intentionally excluded — always true internally, // managed automatically by AudioManager based on RNNoise state. partialize: (state) => ({ - currentVoiceChannelId: state.currentVoiceChannelId, isMuted: state.isMuted, isDeafened: state.isDeafened, inputVolume: state.inputVolume, diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 3f836a3e..eea307ab 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -348,3 +348,37 @@ .animate-step-back { animation: stepBack 0.25s ease-out; } + +/* ── Incoming Call Animations ── */ +@keyframes callRipple { + 0% { transform: translate(-50%, -50%) scale(0.8); opacity: 0.6; } + 100% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; } +} + +@keyframes callGlow { + 0%, 100% { box-shadow: 0 0 20px rgba(134, 239, 172, 0.15); } + 50% { box-shadow: 0 0 30px rgba(134, 239, 172, 0.25); } +} + +@keyframes callButtonGlow { + 0%, 100% { box-shadow: 0 0 12px rgba(134, 239, 172, 0.15); } + 50% { box-shadow: 0 0 20px rgba(134, 239, 172, 0.3); } +} + +.animate-call-ripple { + animation: callRipple 3s ease-out infinite; +} + +.animate-call-glow { + animation: callGlow 3s ease-in-out infinite; +} + +.animate-call-button-glow { + animation: callButtonGlow 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .animate-call-ripple, + .animate-call-glow, + .animate-call-button-glow { animation: none !important; } +} diff --git a/packages/web/src/utils/profileSync.ts b/packages/web/src/utils/profileSync.ts index 9d48669c..027ab64b 100644 --- a/packages/web/src/utils/profileSync.ts +++ b/packages/web/src/utils/profileSync.ts @@ -62,7 +62,7 @@ async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullabl try { const blob = await downloadAsset(homeUser.avatar); const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar)); - payload.avatar = attachment.filename; + payload.avatar = `/api/uploads/${attachment.filename}`; } catch (err) { console.warn('[ProfileSync] Failed to upload avatar to remote:', err); } @@ -75,7 +75,7 @@ async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullabl try { const blob = await downloadAsset(homeUser.banner); const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner)); - payload.banner = attachment.filename; + payload.banner = `/api/uploads/${attachment.filename}`; } catch (err) { console.warn('[ProfileSync] Failed to upload banner to remote:', err); } @@ -108,7 +108,7 @@ async function pullProfileFromRemote(inst: ConnectedInstance): Promise { try { const blob = await downloadAsset(remoteUser.avatar, inst.origin); const attachment = await api.uploads.upload(new File([blob], remoteUser.avatar.split('/').pop() || 'avatar')); - payload.avatar = attachment.filename; + payload.avatar = `/api/uploads/${attachment.filename}`; } catch (err) { console.warn('[ProfileSync] Failed to download/upload avatar from remote:', err); } @@ -121,7 +121,7 @@ async function pullProfileFromRemote(inst: ConnectedInstance): Promise { try { const blob = await downloadAsset(remoteUser.banner, inst.origin); const attachment = await api.uploads.upload(new File([blob], remoteUser.banner.split('/').pop() || 'banner')); - payload.banner = attachment.filename; + payload.banner = `/api/uploads/${attachment.filename}`; } catch (err) { console.warn('[ProfileSync] Failed to download/upload banner from remote:', err); } @@ -206,7 +206,7 @@ export async function syncProfileUpdateToRemotes(update: Partial