feat: security hardening, DB indexes, token revocation, and input validation
- SSRF protection: DNS resolution + private IP blocking on metadata fetcher - Upload security: CSP/X-Frame-Options headers, SVG forced download, nosniff - Auth hardening: JWT secret min length, password min 8 chars, token revocation via password_changed_at - Attachment ownership verification before linking to messages - Message length limit (4000 chars) enforced on client and server - Asset URL validation on avatar/banner updates - Federation instance validation (domain regex, origin scheme, length limits) - DB indexes on all FK columns for query performance - Migrations: nullable moderator columns, dm_messages reply_to FK constraint - File cleanup on avatar/banner replacement and space deletion - Fastify trustProxy, AbortController on fetches, typing map size cap
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -29,6 +29,7 @@ import fs from 'fs';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const app = Fastify({
|
||||
trustProxy: true,
|
||||
logger: {
|
||||
level: 'info',
|
||||
},
|
||||
|
||||
@@ -188,7 +188,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<void> {
|
||||
))
|
||||
.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<string, string[]>();
|
||||
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<number>`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<string, { id: string; dmChannelId: string; userId: string; content: string | null; createdAt: number }>();
|
||||
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<typeof u> => 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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
// Batch fetch friends and space memberships for all page users
|
||||
const pageUserIds = userRows.map(r => r.id);
|
||||
|
||||
// Batch fetch all friends for page users
|
||||
const pageFriendRows = pageUserIds.length > 0
|
||||
? db.select().from(schema.friends).where(
|
||||
or(
|
||||
inArray(schema.friends.userId, pageUserIds),
|
||||
inArray(schema.friends.friendId, pageUserIds),
|
||||
)
|
||||
).all()
|
||||
: [];
|
||||
|
||||
// Build Map<userId, Set<friendId>> for page users
|
||||
const friendIdsByUser = new Map<string, Set<string>>();
|
||||
for (const f of pageFriendRows) {
|
||||
// Map both directions
|
||||
if (pageUserIds.includes(f.userId)) {
|
||||
if (!friendIdsByUser.has(f.userId)) friendIdsByUser.set(f.userId, new Set());
|
||||
friendIdsByUser.get(f.userId)!.add(f.friendId);
|
||||
}
|
||||
if (pageUserIds.includes(f.friendId)) {
|
||||
if (!friendIdsByUser.has(f.friendId)) friendIdsByUser.set(f.friendId, new Set());
|
||||
friendIdsByUser.get(f.friendId)!.add(f.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch all space memberships for page users
|
||||
const pageSpaceMemberRows = pageUserIds.length > 0
|
||||
? db.select({ userId: schema.spaceMembers.userId, spaceId: schema.spaceMembers.spaceId })
|
||||
.from(schema.spaceMembers)
|
||||
.where(inArray(schema.spaceMembers.userId, pageUserIds))
|
||||
.all()
|
||||
: [];
|
||||
|
||||
// Build Map<userId, Set<spaceId>> for page users
|
||||
const spaceIdsByUser = new Map<string, Set<string>>();
|
||||
for (const sm of pageSpaceMemberRows) {
|
||||
if (!spaceIdsByUser.has(sm.userId)) spaceIdsByUser.set(sm.userId, new Set());
|
||||
spaceIdsByUser.get(sm.userId)!.add(sm.spaceId);
|
||||
}
|
||||
|
||||
// Compute mutual counts + relationship for each user
|
||||
const discoverUsers: DiscoverUser[] = userRows.map(row => {
|
||||
const u = sanitizeUser(row);
|
||||
|
||||
// Mutual friends
|
||||
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<void> {
|
||||
// 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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
||||
// 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<void> {
|
||||
?? 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)}"`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
@@ -48,8 +57,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
}, 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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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<void> {
|
||||
app.get<{ Querystring: { url: string } }>('/api/utils/metadata', {
|
||||
preHandler: authenticate,
|
||||
@@ -12,19 +28,57 @@ export async function utilRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
return reply.code(200).send(metadata);
|
||||
} catch (err) {
|
||||
return reply.code(200).send({}); // Fail silently with empty object
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
export function signJwt(payload: JwtPayload): string {
|
||||
@@ -28,7 +29,7 @@ export function signJwt(payload: JwtPayload): string {
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): JwtPayload {
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
const decoded = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }) as JwtPayload;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
@@ -45,10 +46,31 @@ export async function authenticate(
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
// Verify user exists and is not deleted/revoked
|
||||
const db = getDb();
|
||||
const user = db.select({
|
||||
id: schema.users.id,
|
||||
isDeleted: schema.users.isDeleted,
|
||||
passwordChangedAt: schema.users.passwordChangedAt,
|
||||
}).from(schema.users).where(eq(schema.users.id, payload.userId)).get();
|
||||
|
||||
if (!user || user.isDeleted === 1) {
|
||||
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
|
||||
}
|
||||
|
||||
// Reject tokens issued before the last password change (token revocation)
|
||||
if (user.passwordChangedAt && payload.iat) {
|
||||
// JWT iat is in seconds, passwordChangedAt is in milliseconds
|
||||
if (payload.iat < Math.floor(user.passwordChangedAt / 1000)) {
|
||||
return reply.code(401).send({ error: 'Token has been revoked — please log in again', statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
(request as FastifyRequest & { userId: string; username: string }).userId = payload.userId;
|
||||
(request as FastifyRequest & { userId: string; username: string }).username = payload.username;
|
||||
} catch {
|
||||
reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 });
|
||||
return reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +81,7 @@ export async function requireAdmin(
|
||||
const db = getDb();
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
reply.code(403).send({ error: 'Only instance admins can perform this action', statusCode: 403 });
|
||||
return reply.code(403).send({ error: 'Only instance admins can perform this action', statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { connectionManager } from './handler.js';
|
||||
import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js';
|
||||
import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
||||
import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
|
||||
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
|
||||
/**
|
||||
* Re-evaluate SPEAK permission for all participants in voice channels
|
||||
@@ -113,7 +114,8 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
||||
};
|
||||
}
|
||||
|
||||
// Typing timeout tracking
|
||||
// Typing timeout tracking (capped to prevent unbounded growth)
|
||||
const MAX_TYPING_ENTRIES = 10_000;
|
||||
const typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
export function handleClientEvent(
|
||||
@@ -216,6 +218,11 @@ function handleMessageCreate(event: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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))
|
||||
|
||||
@@ -89,6 +89,8 @@ class ConnectionManager {
|
||||
private spaceDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||
// Permission-muted users (SPEAK permission revoked while in voice)
|
||||
private permissionMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||
// Per-user WebSocket rate limiters (shared across all tabs/connections)
|
||||
private userRateLimiters: Map<string, WsRateLimiter> = 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<WebSocket> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
)}
|
||||
{metadata.title && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[16px] text-txt-link font-semibold hover:underline block mb-2"
|
||||
>
|
||||
@@ -67,9 +69,9 @@ export function Embed({ url }: EmbedProps) {
|
||||
</div>
|
||||
{metadata.image && (
|
||||
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover rounded-[4px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : (
|
||||
onlineFriends.map(friend => (
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@ export function FriendsPage() {
|
||||
</div>
|
||||
) : (
|
||||
friends.map(friend => (
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Character counter (shows when near or over limit) */}
|
||||
{content.length > MAX_MESSAGE_LENGTH - 200 && (
|
||||
<span className={`text-[12px] font-medium tabular-nums flex-shrink-0 px-1 ${isOverLimit ? 'text-accent-rose' : 'text-txt-tertiary'}`}>
|
||||
{remaining}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* GIF button */}
|
||||
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="GIF">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { MemberListToggleButton } from './MemberListToggleButton';
|
||||
import { isSelf } from '../../utils/identity';
|
||||
import { joinVoiceChannel } from '../../utils/voice';
|
||||
import { SearchPopover } from '../chat/SearchPopover';
|
||||
import { isDmChannel } from '../../stores/spaceStore';
|
||||
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
|
||||
|
||||
export function MainContent() {
|
||||
// 1. ALL HOOKS AT THE TOP
|
||||
@@ -93,13 +93,13 @@ export function MainContent() {
|
||||
const handleStartVoiceCall = () => {
|
||||
if (!currentChannelId) return;
|
||||
useVoiceStore.getState().setOutgoingCall({ dmChannelId: currentChannelId });
|
||||
wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId });
|
||||
wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId }, getChannelOrigin(currentChannelId));
|
||||
};
|
||||
|
||||
const handleCancelCall = () => {
|
||||
if (!currentChannelId) return;
|
||||
useVoiceStore.getState().setOutgoingCall(null);
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId });
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId }, getChannelOrigin(currentChannelId));
|
||||
};
|
||||
|
||||
if (isInDmCall) {
|
||||
|
||||
@@ -321,6 +321,11 @@ export function UserProfileModal() {
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -135,6 +135,11 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getAvatarGradient } from '../../utils/gradients';
|
||||
import { parseFederatedUsername } from '../../utils/identity';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
|
||||
export function IncomingCallModal() {
|
||||
const incomingCall = useVoiceStore((s) => s.incomingCall);
|
||||
@@ -15,7 +15,7 @@ export function IncomingCallModal() {
|
||||
if (incomingCall) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Auto-reject after timeout
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
|
||||
setIncomingCall(null);
|
||||
}, 30000);
|
||||
}
|
||||
@@ -39,12 +39,12 @@ export function IncomingCallModal() {
|
||||
|
||||
const handleAccept = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
|
||||
setIncomingCall(null);
|
||||
};
|
||||
|
||||
@@ -54,26 +54,29 @@ export function IncomingCallModal() {
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
{/* Call card */}
|
||||
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden">
|
||||
{/* Ring animation background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-status-online/5 animate-ping" style={{ animationDuration: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-status-online/10 animate-ping" style={{ animationDuration: '2s', animationDelay: '0.5s' }} />
|
||||
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden animate-fade-in animate-slide-up">
|
||||
{/* Ripple rings */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 w-[160px] h-[160px] rounded-full border border-status-online/30 animate-call-ripple"
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 w-[160px] h-[160px] rounded-full border border-status-online/30 animate-call-ripple"
|
||||
style={{ animationDelay: '1.5s' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-8 flex flex-col items-center gap-4">
|
||||
{/* Caller avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full flex items-center justify-center text-white text-3xl font-bold" style={{ background: getAvatarGradient(callerAvatarId, callerBaseName).gradient }}>
|
||||
{callerBaseName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/* Ringing phone icon */}
|
||||
<div className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-status-online flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="rounded-full animate-call-glow">
|
||||
<Avatar
|
||||
src={callerMember?.avatar}
|
||||
avatarColor={callerMember?.avatarColor}
|
||||
userId={callerAvatarId}
|
||||
name={callerBaseName}
|
||||
size={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caller info */}
|
||||
@@ -87,10 +90,10 @@ export function IncomingCallModal() {
|
||||
{/* Decline */}
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
className="w-14 h-14 rounded-full bg-accent-rose hover:bg-accent-rose/80 flex items-center justify-center transition-colors group"
|
||||
className="w-14 h-14 rounded-full bg-accent-rose/20 border border-accent-rose/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-accent-rose/35 group"
|
||||
title="Decline"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-accent-rose group-hover:scale-110 transition-transform">
|
||||
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -98,10 +101,10 @@ export function IncomingCallModal() {
|
||||
{/* Accept */}
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="w-14 h-14 rounded-full bg-status-online hover:bg-status-online/80 flex items-center justify-center transition-colors group"
|
||||
className="w-14 h-14 rounded-full bg-status-online/20 border border-status-online/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-status-online/35 animate-call-button-glow group"
|
||||
title="Accept"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-status-online group-hover:scale-110 transition-transform">
|
||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -595,14 +595,16 @@ export const useChatStore = create<ChatState>((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 } };
|
||||
}
|
||||
|
||||
@@ -242,8 +242,9 @@ export const useSocialStore = create<SocialState>((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);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
}),
|
||||
{
|
||||
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<VoiceState>()(
|
||||
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<VoiceState>()(
|
||||
// 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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<UpdateUserReque
|
||||
if (avatarBlob && avatarFilename) {
|
||||
try {
|
||||
const attachment = await inst.api.uploads.upload(new File([avatarBlob], avatarFilename));
|
||||
perInstPayload.avatar = attachment.filename;
|
||||
perInstPayload.avatar = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn(`[ProfileSync] Failed to upload avatar to ${inst.origin}:`, err);
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
|
||||
if (bannerBlob && bannerFilename) {
|
||||
try {
|
||||
const attachment = await inst.api.uploads.upload(new File([bannerBlob], bannerFilename));
|
||||
perInstPayload.banner = attachment.filename;
|
||||
perInstPayload.banner = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn(`[ProfileSync] Failed to upload banner to ${inst.origin}:`, err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user