Server-side: add DB persistence for voice restrictions (schema, migration, ready payload, cleanup on leave). Client-side: fix four bugs that wiped or bypassed server restriction state — leaveVoice() no longer clears global restriction Sets, voice_state_update leave no longer drops amber icons, toggleMic/toggleDeafen now guard against server restrictions, and force-mute/ deafen uses direct setState instead of fragile toggle calls.
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import crypto from 'crypto';
|
|
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
|
|
|
export function runMigrations(db: Database.Database): void {
|
|
console.log('Checking for database migrations...');
|
|
|
|
const tables = [
|
|
{
|
|
name: 'messages',
|
|
columns: [
|
|
{ name: 'reply_to_id', type: 'TEXT REFERENCES messages(id) ON DELETE SET NULL' }
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'status', type: "TEXT DEFAULT 'offline'" },
|
|
{ name: 'custom_status', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'roles',
|
|
columns: [
|
|
{ name: 'permissions', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'dm_messages',
|
|
columns: [
|
|
{ name: 'edited_at', type: 'INTEGER' },
|
|
{ name: 'reply_to_id', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'attachments',
|
|
columns: [
|
|
{ name: 'dm_message_id', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'dm_members',
|
|
columns: [
|
|
{ name: 'closed', type: 'INTEGER DEFAULT 0' }
|
|
]
|
|
},
|
|
{
|
|
name: 'dm_channels',
|
|
columns: [
|
|
{ name: 'owner_id', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'is_admin', type: 'INTEGER DEFAULT 0' }
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'home_instance', type: 'TEXT' },
|
|
{ name: 'replicated_instances', type: "TEXT DEFAULT '[]'" },
|
|
{ name: 'home_user_id', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'instance_settings',
|
|
columns: [
|
|
{ name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" },
|
|
{ name: 'worker_id', type: 'INTEGER' },
|
|
{ name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' },
|
|
{ name: 'registration_open', type: 'INTEGER' }
|
|
]
|
|
},
|
|
{
|
|
name: 'spaces',
|
|
columns: [
|
|
{ name: 'visibility', type: "TEXT DEFAULT 'private'" },
|
|
{ name: 'description', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'spaces',
|
|
columns: [
|
|
{ name: 'banner', type: 'TEXT' }
|
|
]
|
|
}
|
|
];
|
|
|
|
for (const table of tables) {
|
|
const tableInfo = db.pragma(`table_info(${table.name})`) as { name: string }[];
|
|
const existingColumns = new Set(tableInfo.map(c => c.name));
|
|
|
|
for (const column of table.columns) {
|
|
if (!existingColumns.has(column.name)) {
|
|
console.log(`Migrating: Adding column ${column.name} to ${table.name}`);
|
|
try {
|
|
db.exec(`ALTER TABLE ${table.name} ADD COLUMN ${column.name} ${column.type}`);
|
|
} catch (error) {
|
|
console.error(`Failed to add column ${column.name} to ${table.name}:`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure channel_overrides table exists (idempotent)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS channel_overrides (
|
|
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
target_type TEXT NOT NULL,
|
|
target_id TEXT NOT NULL,
|
|
allow TEXT NOT NULL DEFAULT '0',
|
|
deny TEXT NOT NULL DEFAULT '0',
|
|
PRIMARY KEY (channel_id, target_type, target_id)
|
|
);
|
|
`);
|
|
|
|
// Ensure bans table exists (idempotent)
|
|
db.exec(`
|
|
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 NOT NULL REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (space_id, user_id)
|
|
);
|
|
`);
|
|
|
|
// Ensure join_requests table exists (idempotent)
|
|
db.exec(`
|
|
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
|
|
);
|
|
`);
|
|
|
|
// Ensure voice_restrictions table exists (idempotent)
|
|
db.exec(`
|
|
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 NOT NULL REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (space_id, user_id, restriction_type)
|
|
);
|
|
`);
|
|
|
|
// ─── RBAC Migration: Ensure @everyone roles exist for all spaces ─────────
|
|
migrateEveryoneRoles(db);
|
|
|
|
// ─── Instance settings: ensure default row exists ──────────────────────────
|
|
migrateInstanceSettings(db);
|
|
|
|
// ─── Worker ID: ensure a unique Snowflake worker ID is persisted ───────────
|
|
migrateWorkerId(db);
|
|
|
|
// ─── Namespace replicated users: ensure all federated users use user@domain ─
|
|
migrateReplicatedUsernames(db);
|
|
|
|
// ─── Admin flag: ensure at least one admin exists (first registered user) ──
|
|
migrateFirstAdmin(db);
|
|
|
|
// ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─
|
|
migrateCorruptedReadStates(db);
|
|
|
|
console.log('Migrations complete.');
|
|
}
|
|
|
|
/** Ensure the single-row instance_settings row exists */
|
|
function migrateInstanceSettings(db: Database.Database): void {
|
|
const row = db.prepare('SELECT id FROM instance_settings WHERE id = 1').get();
|
|
if (!row) {
|
|
db.prepare(
|
|
'INSERT OR IGNORE INTO instance_settings (id, max_bitrate_kbps, min_bitrate_kbps, bitrate_step_kbps, allowed_resolutions, allowed_framerates, max_resolution, max_framerate, updated_at) VALUES (1, 20000, 500, 500, ?, ?, 1080, 60, ?)'
|
|
).run('540,720,1080', '30,45,60', Date.now());
|
|
console.log('Migrating: Inserted default instance_settings row');
|
|
}
|
|
}
|
|
|
|
/** Ensure at least one user has is_admin = 1 (the earliest registered user) */
|
|
function migrateFirstAdmin(db: Database.Database): void {
|
|
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
|
|
if (!anyAdmin) {
|
|
const firstUser = db.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1').get() as { id: string } | undefined;
|
|
if (firstUser) {
|
|
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id);
|
|
console.log(`Migrating: Set first user ${firstUser.id} as instance admin`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure a unique Snowflake worker ID is persisted for this instance.
|
|
* Generated randomly on first boot (0-1023) and never changed.
|
|
* This prevents ID collisions between federated instances that would
|
|
* otherwise share worker_id = 1 when running as Docker PID 1.
|
|
*/
|
|
function migrateWorkerId(db: Database.Database): void {
|
|
const row = db.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number | null } | undefined;
|
|
if (!row || row.worker_id === null) {
|
|
const workerId = crypto.randomInt(0, 1024); // 0-1023 (10-bit range)
|
|
db.prepare('UPDATE instance_settings SET worker_id = ? WHERE id = 1').run(workerId);
|
|
console.log(`Migrating: Generated Snowflake worker ID ${workerId} for this instance`);
|
|
}
|
|
}
|
|
|
|
/** For each space, ensure an @everyone role exists with id === space.id */
|
|
function migrateEveryoneRoles(db: Database.Database): void {
|
|
const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[];
|
|
const now = Date.now();
|
|
const defaultPerms = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS);
|
|
const adminPerms = permissionsToString(ALL_PERMISSIONS);
|
|
|
|
const insertRole = db.prepare(
|
|
'INSERT OR IGNORE INTO roles (id, space_id, name, color, position, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
|
|
for (const space of spaces) {
|
|
// Create @everyone role if it doesn't exist (id = space.id)
|
|
insertRole.run(space.id, space.id, '@everyone', '#b9bbbe', 0, defaultPerms, now);
|
|
}
|
|
|
|
// Migrate existing admin members: ensure an Admin role exists and assign it
|
|
// Only run if the old `role` column still exists on space_members
|
|
const smColumns = db.pragma('table_info(space_members)') as { name: string }[];
|
|
const hasRoleColumn = smColumns.some(c => c.name === 'role');
|
|
const adminMembers = hasRoleColumn
|
|
? db.prepare("SELECT space_id, user_id FROM space_members WHERE role = 'admin'").all() as { space_id: string; user_id: string }[]
|
|
: [];
|
|
|
|
if (adminMembers.length > 0) {
|
|
// Group by space
|
|
const spaceAdmins = new Map<string, string[]>();
|
|
for (const row of adminMembers) {
|
|
let arr = spaceAdmins.get(row.space_id);
|
|
if (!arr) { arr = []; spaceAdmins.set(row.space_id, arr); }
|
|
arr.push(row.user_id);
|
|
}
|
|
|
|
const checkAdminRole = db.prepare(
|
|
"SELECT id FROM roles WHERE space_id = ? AND name = 'Admin' AND permissions = ?"
|
|
);
|
|
const insertMemberRole = db.prepare(
|
|
'INSERT OR IGNORE INTO member_roles (space_id, user_id, role_id) VALUES (?, ?, ?)'
|
|
);
|
|
|
|
for (const [spaceId, userIds] of spaceAdmins) {
|
|
// Find or create Admin role for this space
|
|
let adminRole = checkAdminRole.get(spaceId, adminPerms) as { id: string } | undefined;
|
|
if (!adminRole) {
|
|
// Generate a simple unique ID for the admin role
|
|
const adminRoleId = `${spaceId}-admin`;
|
|
insertRole.run(adminRoleId, spaceId, 'Admin', '#e74c3c', 1, adminPerms, now);
|
|
adminRole = { id: adminRoleId };
|
|
}
|
|
|
|
for (const userId of userIds) {
|
|
insertMemberRole.run(spaceId, userId, adminRole.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */
|
|
function migrateCorruptedReadStates(db: Database.Database): void {
|
|
const deleted = db.prepare(
|
|
"DELETE FROM read_states WHERE last_read_message_id NOT GLOB '[0-9]*' OR last_read_message_id GLOB '*[^0-9]*'"
|
|
).run();
|
|
if (deleted.changes > 0) {
|
|
console.log(`Migrating: Cleaned up ${deleted.changes} corrupted read_states rows`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net"
|
|
* Frees plain usernames for native user creation and makes all federated users
|
|
* visually consistent. Safe because JWTs validate by userId, not username.
|
|
*/
|
|
function migrateReplicatedUsernames(db: Database.Database): void {
|
|
const rows = db.prepare(
|
|
"SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'"
|
|
).all() as { id: string; username: string; home_instance: string }[];
|
|
|
|
if (rows.length === 0) return;
|
|
|
|
const update = db.prepare('UPDATE users SET username = ? WHERE id = ?');
|
|
for (const row of rows) {
|
|
const newUsername = `${row.username}@${row.home_instance}`;
|
|
update.run(newUsername, row.id);
|
|
console.log(`Migrating: Renamed replicated user "${row.username}" → "${newUsername}"`);
|
|
}
|
|
}
|