2146 lines
88 KiB
TypeScript
2146 lines
88 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import crypto from 'crypto';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
|
import { generateThumbnail, isResizableImage, probeImageDimensions, probeMediaMeta, generateVideoThumbnail } from '../utils/thumbnail.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: 'attachments',
|
|
columns: [
|
|
{ name: 'thumbnail_filename', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
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: 'bitrate_matrix_overrides', type: 'TEXT DEFAULT NULL' }
|
|
]
|
|
},
|
|
{
|
|
name: 'spaces',
|
|
columns: [
|
|
{ name: 'visibility', type: "TEXT DEFAULT 'private'" },
|
|
{ name: 'description', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'spaces',
|
|
columns: [
|
|
{ name: 'banner', type: 'TEXT' }
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'banner', type: 'TEXT' },
|
|
{ name: 'accent_color', type: 'TEXT' },
|
|
{ name: 'bio', type: 'TEXT' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'avatar_color', type: 'TEXT' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'is_deleted', type: 'INTEGER DEFAULT 0' },
|
|
]
|
|
},
|
|
{
|
|
name: 'spaces',
|
|
columns: [
|
|
{ name: 'avatar_color', type: 'TEXT' },
|
|
]
|
|
},
|
|
{
|
|
name: 'channels',
|
|
columns: [
|
|
{ name: 'category_id', type: 'TEXT' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'profile_updated_at', type: 'INTEGER' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'discoverable', type: 'INTEGER DEFAULT 1' },
|
|
]
|
|
},
|
|
{
|
|
name: 'attachments',
|
|
columns: [
|
|
{ name: 'uploader_id', type: 'TEXT' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'password_changed_at', type: 'INTEGER' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'show_activity', type: 'INTEGER NOT NULL DEFAULT 1' },
|
|
]
|
|
},
|
|
{
|
|
name: 'attachments',
|
|
columns: [
|
|
{ name: 'width', type: 'INTEGER' },
|
|
{ name: 'height', type: 'INTEGER' },
|
|
{ name: 'duration', type: 'REAL' },
|
|
]
|
|
},
|
|
{
|
|
name: 'instance_settings',
|
|
columns: [
|
|
{ name: 'max_upload_size_bytes', type: 'INTEGER' }
|
|
]
|
|
},
|
|
{
|
|
name: 'instance_settings',
|
|
columns: [
|
|
{ name: 'allow_custom_bitrate', type: 'INTEGER NOT NULL DEFAULT 1' }
|
|
]
|
|
},
|
|
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
|
|
// or it will race with the tenor_api_key → gif_api_key rename migration
|
|
{
|
|
name: 'dm_channels',
|
|
columns: [
|
|
{ name: 'owner_home_user_id', type: 'TEXT' },
|
|
{ name: 'owner_home_instance', type: 'TEXT' },
|
|
{ name: 'deleted_at', type: 'INTEGER' },
|
|
]
|
|
},
|
|
{
|
|
name: 'dm_messages',
|
|
columns: [
|
|
{ name: 'source_instance', type: 'TEXT' },
|
|
{ name: 'source_message_id', type: 'TEXT' },
|
|
{ name: 'encryption_version', type: 'INTEGER DEFAULT 0' },
|
|
]
|
|
},
|
|
{
|
|
name: 'dm_messages',
|
|
columns: [
|
|
{ name: 'type', type: "TEXT NOT NULL DEFAULT 'user'" },
|
|
]
|
|
},
|
|
{
|
|
name: 'attachments',
|
|
columns: [
|
|
{ name: 'source_url', type: 'TEXT' },
|
|
]
|
|
},
|
|
{
|
|
name: 'instance_settings',
|
|
columns: [
|
|
{ name: 'federation_relay_enabled', type: 'INTEGER NOT NULL DEFAULT 1' },
|
|
{ name: 'federation_relay_ttl_days', type: 'INTEGER NOT NULL DEFAULT 30' },
|
|
]
|
|
},
|
|
{
|
|
name: 'instance_settings',
|
|
columns: [
|
|
{ name: 'default_auto_rotate_interval_days', type: 'INTEGER NOT NULL DEFAULT 90' },
|
|
]
|
|
},
|
|
{
|
|
name: 'federation_peers',
|
|
columns: [
|
|
{ name: 'pending_hmac_secret', type: 'TEXT' },
|
|
{ name: 'secret_rotation_at', type: 'INTEGER' },
|
|
{ name: 'secret_rotated_at', type: 'INTEGER' },
|
|
{ name: 'auto_rotate_interval_days', type: 'INTEGER NOT NULL DEFAULT 90' },
|
|
]
|
|
},
|
|
{
|
|
name: 'users',
|
|
columns: [
|
|
{ name: 'federation_registry_updated_at', type: 'INTEGER DEFAULT 0' },
|
|
]
|
|
},
|
|
];
|
|
|
|
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 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 channel_categories table exists (idempotent)
|
|
db.exec(`
|
|
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
|
|
);
|
|
`);
|
|
|
|
// 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 REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (space_id, user_id, restriction_type)
|
|
);
|
|
`);
|
|
|
|
// Ensure embeds table exists (idempotent)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS embeds (
|
|
id TEXT PRIMARY KEY,
|
|
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
dm_message_id TEXT REFERENCES dm_messages(id) ON DELETE CASCADE,
|
|
url TEXT NOT NULL,
|
|
embed_type TEXT NOT NULL CHECK (embed_type IN ('generic', 'video', 'image', 'audio', 'rich')),
|
|
provider TEXT,
|
|
title TEXT,
|
|
description TEXT,
|
|
image TEXT,
|
|
embed_url TEXT,
|
|
width INTEGER,
|
|
height INTEGER,
|
|
color TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
CHECK (
|
|
(message_id IS NOT NULL AND dm_message_id IS NULL) OR
|
|
(message_id IS NULL AND dm_message_id IS NOT NULL)
|
|
)
|
|
);
|
|
`);
|
|
|
|
// ─── Federation tables ───────────────────────────────────────────────────
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS federation_peers (
|
|
id TEXT PRIMARY KEY,
|
|
origin TEXT NOT NULL UNIQUE,
|
|
instance_name TEXT,
|
|
hmac_secret TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
last_seen_at INTEGER,
|
|
last_failure_at INTEGER,
|
|
consecutive_failures INTEGER DEFAULT 0,
|
|
last_synced_at INTEGER DEFAULT 0,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS federation_outbox (
|
|
id TEXT PRIMARY KEY,
|
|
peer_id TEXT NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE,
|
|
context_id TEXT NOT NULL,
|
|
entity_id TEXT NOT NULL,
|
|
context_type TEXT NOT NULL DEFAULT 'dm',
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
encryption_version INTEGER DEFAULT 0,
|
|
attempts INTEGER DEFAULT 0,
|
|
next_retry_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
UNIQUE(peer_id, entity_id)
|
|
);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS federation_file_queue (
|
|
id TEXT PRIMARY KEY,
|
|
peer_origin TEXT NOT NULL,
|
|
dm_message_id TEXT NOT NULL,
|
|
source_url TEXT NOT NULL,
|
|
target_filename TEXT,
|
|
original_name TEXT NOT NULL,
|
|
mimetype TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
rejection_reason TEXT,
|
|
attempts INTEGER DEFAULT 0,
|
|
next_retry_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS federation_mutation_log (
|
|
id TEXT PRIMARY KEY,
|
|
entity_id TEXT NOT NULL,
|
|
context_id TEXT NOT NULL,
|
|
context_type TEXT NOT NULL DEFAULT 'dm',
|
|
mutation_type TEXT NOT NULL,
|
|
mutated_at INTEGER NOT NULL,
|
|
payload TEXT
|
|
);
|
|
`);
|
|
|
|
// ─── User federation registry ───────────────────────────────────────────────
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS user_federation_registry (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
origin TEXT NOT NULL,
|
|
label TEXT NOT NULL DEFAULT '',
|
|
username TEXT NOT NULL DEFAULT '',
|
|
remote_user_id TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'connected',
|
|
added_at INTEGER NOT NULL,
|
|
last_connected_at INTEGER,
|
|
disconnected_at INTEGER,
|
|
error_message TEXT,
|
|
PRIMARY KEY (user_id, origin)
|
|
);
|
|
`);
|
|
|
|
// ─── Legacy permissions: convert JSON arrays to decimal strings ───────────
|
|
migrateLegacyPermissions(db);
|
|
|
|
// ─── 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);
|
|
|
|
// ─── Remove USE_VOICE_ACTIVITY bit and shift STREAM/DISCONNECT_MEMBERS down ─
|
|
migrateRemoveVoiceActivityBit(db);
|
|
|
|
// ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─
|
|
migrateCorruptedReadStates(db);
|
|
|
|
// ─── 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);
|
|
|
|
// ─── Lowercase all existing usernames ────────────────────────────────────────
|
|
migrateLowercaseUsernames(db);
|
|
|
|
// ─── Convert video channels to voice (video type removed) ─────────────────
|
|
migrateVideoChannels(db);
|
|
|
|
// ─── Backfill profile_updated_at from created_at ──────────────────────────
|
|
migrateProfileUpdatedAt(db);
|
|
|
|
// ─── Ensure user_space_layout table exists ────────────────────────────────
|
|
db.exec(`
|
|
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
|
|
);
|
|
`);
|
|
|
|
// ─── Add position column to space_folder_members ──────────────────────────
|
|
{
|
|
const sfmColumns = db.pragma('table_info(space_folder_members)') as { name: string }[];
|
|
if (!sfmColumns.some(c => c.name === 'position')) {
|
|
db.exec('ALTER TABLE space_folder_members ADD COLUMN position INTEGER DEFAULT 0');
|
|
console.log('Migrating: Added position column to space_folder_members');
|
|
}
|
|
}
|
|
|
|
// ─── Remove FK constraint from space_folder_members (federated spaces) ────
|
|
{
|
|
const tableInfo = db.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='space_folder_members'"
|
|
).get() as { sql: string } | undefined;
|
|
|
|
if (tableInfo && tableInfo.sql.includes('REFERENCES spaces')) {
|
|
console.log('Migrating: Removing FK constraint from space_folder_members...');
|
|
db.exec(`
|
|
CREATE TABLE space_folder_members_new (
|
|
folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE,
|
|
space_id TEXT NOT NULL,
|
|
position INTEGER DEFAULT 0,
|
|
PRIMARY KEY (folder_id, space_id)
|
|
);
|
|
INSERT INTO space_folder_members_new SELECT folder_id, space_id, position FROM space_folder_members;
|
|
DROP TABLE space_folder_members;
|
|
ALTER TABLE space_folder_members_new RENAME TO space_folder_members;
|
|
`);
|
|
}
|
|
}
|
|
|
|
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
|
|
migrateDmMessagesReplyToFk(db);
|
|
|
|
// ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ────────────────────
|
|
migrateRenameGifApiKey(db);
|
|
|
|
// ─── Add indexes on FK columns for query performance ─────────────────────
|
|
migrateAddIndexes(db);
|
|
|
|
// ─── Embed indexes (outside fast-path guard so they run on existing DBs) ──
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_embeds_message_id ON embeds(message_id)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_embeds_dm_message_id ON embeds(dm_message_id)');
|
|
|
|
// ─── Clean up stale attachment records for profile images ───────────────
|
|
migrateCleanupProfileAttachmentRecords(db);
|
|
|
|
// ─── Add category_overrides table ────────────────────────────────────────
|
|
migrateCategoryOverrides(db);
|
|
|
|
// ─── Add FK constraint to attachments.dm_message_id ─────────────────────
|
|
migrateAttachmentsDmMessageFk(db);
|
|
|
|
// ─── Federation indexes ──────────────────────────────────────────────────
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_messages_source_unique ON dm_messages(source_instance, source_message_id) WHERE source_instance IS NOT NULL`);
|
|
// idx_dm_federated is created by migrateDmChannelsFederatedId (after column rename)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_outbox_retry ON federation_outbox(next_retry_at)`);
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_mutation_log_time ON federation_mutation_log(mutated_at)`);
|
|
|
|
// ─── Ensure federation relay is enabled (fix: was incorrectly defaulting to 0) ─
|
|
try {
|
|
const relayState = db.prepare('SELECT federation_relay_enabled FROM instance_settings WHERE id = 1').get() as { federation_relay_enabled: number } | undefined;
|
|
if (relayState && relayState.federation_relay_enabled === 0) {
|
|
db.prepare('UPDATE instance_settings SET federation_relay_enabled = 1 WHERE id = 1').run();
|
|
console.log('Federation: Enabled relay (was incorrectly defaulting to disabled)');
|
|
}
|
|
} catch { /* column may not exist yet on first run */ }
|
|
|
|
// ─── Rename canonical_pair_id → federated_id, add group DM columns ───────
|
|
migrateDmChannelsFederatedId(db);
|
|
|
|
// ─── Fix ownerId on 1-on-1 DMs (must run before federated_id backfill) ──
|
|
migrateFixOneOnOneOwnerIds(db);
|
|
|
|
// ─── Backfill federated_id for existing 1-on-1 DM channels ──────────────
|
|
// (Runs after migrateDmChannelsFederatedId so the federated_id column is guaranteed to exist)
|
|
try {
|
|
// Find 1-on-1 DM channels that don't have a federated_id yet
|
|
const channelsNeedingPairId = db.prepare(`
|
|
SELECT dc.id, GROUP_CONCAT(COALESCE(u.home_user_id, u.id)) as home_ids
|
|
FROM dm_channels dc
|
|
JOIN dm_members dm ON dc.id = dm.dm_channel_id
|
|
JOIN users u ON dm.user_id = u.id
|
|
WHERE dc.federated_id IS NULL
|
|
AND dc.owner_id IS NULL
|
|
GROUP BY dc.id
|
|
HAVING COUNT(dm.user_id) = 2
|
|
`).all() as { id: string; home_ids: string }[];
|
|
|
|
if (channelsNeedingPairId.length > 0) {
|
|
// crypto is already imported at the top of this file
|
|
const update = db.prepare('UPDATE dm_channels SET federated_id = ? WHERE id = ? AND federated_id IS NULL');
|
|
|
|
let backfilled = 0;
|
|
for (const channel of channelsNeedingPairId) {
|
|
// home_ids is like "homeUserId1,homeUserId2" (COALESCE handles NULL home_user_id)
|
|
const homeUserIds = channel.home_ids.split(',');
|
|
|
|
if (homeUserIds.length === 2) {
|
|
const sorted = homeUserIds.sort();
|
|
const pairId = crypto.createHash('sha256').update(sorted.join(':')).digest('hex').slice(0, 32);
|
|
// Check if this pairId already exists on a relay-created channel
|
|
const existing = db.prepare('SELECT id FROM dm_channels WHERE federated_id = ?').get(pairId) as { id: string } | undefined;
|
|
if (existing) {
|
|
// A relay-created duplicate exists — merge it into this (older) channel, then set the pair ID
|
|
db.prepare('UPDATE dm_messages SET dm_channel_id = ? WHERE dm_channel_id = ?').run(channel.id, existing.id);
|
|
db.prepare('INSERT OR IGNORE INTO dm_members (dm_channel_id, user_id, closed) SELECT ?, user_id, closed FROM dm_members WHERE dm_channel_id = ?').run(channel.id, existing.id);
|
|
db.prepare('DELETE FROM dm_members WHERE dm_channel_id = ?').run(existing.id);
|
|
db.prepare('DELETE FROM dm_channels WHERE id = ?').run(existing.id);
|
|
console.log(`Federation: Merged relay-duplicate channel ${existing.id} into original ${channel.id}`);
|
|
}
|
|
update.run(pairId, channel.id);
|
|
backfilled++;
|
|
}
|
|
}
|
|
|
|
if (backfilled > 0) {
|
|
console.log(`Federation: Backfilled federated_id for ${backfilled} existing DM channel(s)`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Federation federated_id backfill failed (non-fatal):', err);
|
|
}
|
|
|
|
// ─── Merge duplicate DM channels with same federated_id ──────────────────
|
|
try {
|
|
// Find federated_ids that appear more than once
|
|
const duplicates = db.prepare(`
|
|
SELECT federated_id, GROUP_CONCAT(id) as channel_ids
|
|
FROM dm_channels
|
|
WHERE federated_id IS NOT NULL
|
|
GROUP BY federated_id
|
|
HAVING COUNT(*) > 1
|
|
`).all() as { federated_id: string; channel_ids: string }[];
|
|
|
|
for (const dup of duplicates) {
|
|
const ids = dup.channel_ids.split(',');
|
|
// Keep the oldest channel (lowest snowflake ID = created first), move messages from newer ones
|
|
ids.sort();
|
|
const keepId = ids[0];
|
|
const removeIds = ids.slice(1);
|
|
|
|
for (const removeId of removeIds) {
|
|
// Move messages from duplicate channel to the keeper
|
|
db.prepare('UPDATE dm_messages SET dm_channel_id = ? WHERE dm_channel_id = ?').run(keepId, removeId);
|
|
// Move read states from duplicate channel to the keeper (ignore conflicts)
|
|
db.prepare('INSERT OR IGNORE INTO read_states SELECT user_id, ?, last_read_message_id, updated_at FROM read_states WHERE channel_id = ?').run(keepId, removeId);
|
|
db.prepare('DELETE FROM read_states WHERE channel_id = ?').run(removeId);
|
|
// Move DM members (ignore conflicts where member already exists in keeper)
|
|
db.prepare('INSERT OR IGNORE INTO dm_members (dm_channel_id, user_id, closed) SELECT ?, user_id, closed FROM dm_members WHERE dm_channel_id = ?').run(keepId, removeId);
|
|
// Delete duplicate members and channel
|
|
db.prepare('DELETE FROM dm_members WHERE dm_channel_id = ?').run(removeId);
|
|
db.prepare('DELETE FROM dm_channels WHERE id = ?').run(removeId);
|
|
}
|
|
|
|
if (removeIds.length > 0) {
|
|
console.log(`Federation: Merged ${removeIds.length} duplicate DM channel(s) for pair ${dup.federated_id} into ${keepId}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Federation DM channel merge failed (non-fatal):', err);
|
|
}
|
|
|
|
// ─── Backfill federation mutation log for existing DM messages (idempotent) ─
|
|
try {
|
|
const mutationLogExists = (db.pragma('table_info(federation_mutation_log)') as { name: string }[]).length > 0;
|
|
if (mutationLogExists) {
|
|
const count = (db.prepare('SELECT COUNT(*) as c FROM federation_mutation_log').get() as { c: number }).c;
|
|
if (count === 0) {
|
|
const result = db.prepare(`
|
|
INSERT OR IGNORE INTO federation_mutation_log (id, entity_id, context_id, context_type, mutation_type, mutated_at)
|
|
SELECT id, id, dm_channel_id, 'dm', 'create', created_at
|
|
FROM dm_messages
|
|
WHERE source_instance IS NULL
|
|
`).run();
|
|
if (result.changes > 0) {
|
|
console.log(`Federation: Backfilled ${result.changes} mutation log entries for existing DM messages`);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Federation mutation log backfill failed (non-fatal):', err);
|
|
}
|
|
|
|
migrateResetFederationSyncForLegacyDms(db);
|
|
|
|
migrateGeneralizeOutbox(db);
|
|
|
|
// ─── Federation upload size mismatch columns ─────────────────────────────
|
|
try {
|
|
db.exec(`ALTER TABLE attachments ADD COLUMN federation_status TEXT`);
|
|
} catch { /* column already exists */ }
|
|
|
|
try {
|
|
db.exec(`ALTER TABLE attachments ADD COLUMN federation_meta TEXT`);
|
|
} catch { /* column already exists */ }
|
|
|
|
try {
|
|
db.exec(`ALTER TABLE federation_peers ADD COLUMN remote_max_upload_size INTEGER`);
|
|
} catch { /* column already exists */ }
|
|
|
|
// ─── Data integrity: repair group DMs with nulled-out owner_id ───────────
|
|
// A bug in processOwnershipTransferEvent (fixed in cd7aff0) could set
|
|
// owner_id to NULL when resolveLocalUser failed, converting a group DM into
|
|
// a 1-on-1-looking channel. Detect these by finding dm_channels with a
|
|
// UUID-format federated_id (group DMs) but NULL owner_id, and restore the
|
|
// owner from the first remaining member.
|
|
const corruptedGroups = db.prepare(`
|
|
SELECT c.id, c.federated_id
|
|
FROM dm_channels c
|
|
WHERE c.owner_id IS NULL
|
|
AND c.federated_id IS NOT NULL
|
|
AND c.deleted_at IS NULL
|
|
AND length(c.federated_id) = 36
|
|
AND c.federated_id LIKE '________-____-____-____-____________'
|
|
`).all() as Array<{ id: string; federated_id: string }>;
|
|
|
|
for (const ch of corruptedGroups) {
|
|
const firstMember = db.prepare(
|
|
`SELECT user_id FROM dm_members WHERE dm_channel_id = ? LIMIT 1`
|
|
).get(ch.id) as { user_id: string } | undefined;
|
|
|
|
if (firstMember) {
|
|
db.prepare(`UPDATE dm_channels SET owner_id = ? WHERE id = ?`).run(firstMember.user_id, ch.id);
|
|
console.log(`[migration] Repaired group DM ${ch.id}: restored owner_id to ${firstMember.user_id}`);
|
|
}
|
|
}
|
|
|
|
if (corruptedGroups.length > 0) {
|
|
console.log(`[migration] Repaired ${corruptedGroups.length} corrupted group DM(s).`);
|
|
}
|
|
|
|
// ─── Data integrity: normalize homeInstance to bare domain ────────────────
|
|
// resolveOrCreateReplicatedUser historically stored homeInstance as a full URL
|
|
// (e.g., "https://nova.ddns.net") while auth registration stored bare domains
|
|
// ("nova.ddns.net"). Normalize all to bare domain for consistent identity matching.
|
|
const fullUrlUsers = db.prepare(`
|
|
SELECT id, home_instance
|
|
FROM users
|
|
WHERE home_instance IS NOT NULL
|
|
AND (home_instance LIKE 'http://%' OR home_instance LIKE 'https://%')
|
|
AND is_deleted = 0
|
|
`).all() as Array<{ id: string; home_instance: string }>;
|
|
|
|
for (const u of fullUrlUsers) {
|
|
let domain: string;
|
|
try {
|
|
domain = new URL(u.home_instance).hostname;
|
|
} catch {
|
|
domain = u.home_instance.replace(/^https?:\/\//, '').split('/')[0] ?? u.home_instance;
|
|
}
|
|
db.prepare(`UPDATE users SET home_instance = ? WHERE id = ?`).run(domain, u.id);
|
|
}
|
|
|
|
if (fullUrlUsers.length > 0) {
|
|
console.log(`[migration] Normalized ${fullUrlUsers.length} homeInstance value(s) from full URL to bare domain.`);
|
|
}
|
|
|
|
// ─── Data integrity: merge duplicate federated user stubs ─────────────────
|
|
// The same remote user could accumulate multiple replicated records because
|
|
// resolveLocalUser matched on homeUserId but missed stubs created with a
|
|
// different homeUserId (e.g., auth registration vs S2S relay). Now that
|
|
// homeInstance is normalized to bare domain, we can detect and merge duplicates.
|
|
//
|
|
// Safety guard: at least one user in the pair must be a stub
|
|
// (passwordHash = '!federation-replicated'). Two real accounts from the same
|
|
// instance are different people who share a relayed DM, not duplicates.
|
|
//
|
|
// Detection criteria (at least one must match, PLUS same homeInstance domain):
|
|
// 1. Shared 1-on-1 DM membership (only when at least one is a stub)
|
|
// 2. Username cross-reference (one's homeUserId in the other's username base)
|
|
// 3. homeUserId cross-match (same homeUserId, missed due to old format mismatch)
|
|
//
|
|
// Winner selection: real account > stub, then most profile data, then lower ID.
|
|
|
|
const federatedUsers = db.prepare(`
|
|
SELECT id, username, display_name, avatar, banner, bio, avatar_color,
|
|
home_instance, home_user_id, password_hash, is_deleted
|
|
FROM users
|
|
WHERE home_instance IS NOT NULL
|
|
AND is_deleted = 0
|
|
`).all() as Array<{
|
|
id: string; username: string; display_name: string | null;
|
|
avatar: string | null; banner: string | null; bio: string | null;
|
|
avatar_color: string | null; home_instance: string; home_user_id: string | null;
|
|
password_hash: string; is_deleted: number;
|
|
}>;
|
|
|
|
// Group by normalized homeInstance domain
|
|
const domainGroups = new Map<string, typeof federatedUsers>();
|
|
for (const u of federatedUsers) {
|
|
const domain = u.home_instance.toLowerCase();
|
|
const group = domainGroups.get(domain);
|
|
if (group) group.push(u);
|
|
else domainGroups.set(domain, [u]);
|
|
}
|
|
|
|
type MergePair = { winner: typeof federatedUsers[0]; loser: typeof federatedUsers[0]; reason: string };
|
|
const mergePairs: MergePair[] = [];
|
|
|
|
for (const [domain, users] of domainGroups) {
|
|
if (users.length < 2) continue;
|
|
|
|
// Check all pairs within this domain group
|
|
for (let i = 0; i < users.length; i++) {
|
|
for (let j = i + 1; j < users.length; j++) {
|
|
const a = users[i]!;
|
|
const b = users[j]!;
|
|
|
|
// Safety guard: at least one must be a stub. Two real accounts from the
|
|
// same instance are different people (they share relayed DMs, not identities).
|
|
const aIsStub = a.password_hash === '!federation-replicated';
|
|
const bIsStub = b.password_hash === '!federation-replicated';
|
|
if (!aIsStub && !bIsStub) continue;
|
|
|
|
let reason: string | null = null;
|
|
|
|
// Criterion 1: shared 1-on-1 DM membership
|
|
if (!reason) {
|
|
const shared = db.prepare(`
|
|
SELECT m1.dm_channel_id
|
|
FROM dm_members m1
|
|
JOIN dm_members m2 ON m1.dm_channel_id = m2.dm_channel_id
|
|
JOIN dm_channels c ON c.id = m1.dm_channel_id
|
|
WHERE m1.user_id = ? AND m2.user_id = ?
|
|
AND c.owner_id IS NULL
|
|
LIMIT 1
|
|
`).get(a.id, b.id) as { dm_channel_id: string } | undefined;
|
|
if (shared) reason = `shared 1-on-1 DM channel ${shared.dm_channel_id}`;
|
|
}
|
|
|
|
// Criterion 2: username cross-reference
|
|
if (!reason) {
|
|
const aBase = a.username.includes('@') ? a.username.split('@')[0]! : a.username;
|
|
const bBase = b.username.includes('@') ? b.username.split('@')[0]! : b.username;
|
|
if (a.home_user_id && bBase.toLowerCase() === a.home_user_id.toLowerCase()) {
|
|
reason = `b username base "${bBase}" matches a homeUserId "${a.home_user_id}"`;
|
|
} else if (b.home_user_id && aBase.toLowerCase() === b.home_user_id.toLowerCase()) {
|
|
reason = `a username base "${aBase}" matches b homeUserId "${b.home_user_id}"`;
|
|
}
|
|
}
|
|
|
|
// Criterion 3: homeUserId cross-match
|
|
if (!reason) {
|
|
if (a.home_user_id && b.home_user_id && a.home_user_id === b.home_user_id) {
|
|
reason = `same homeUserId "${a.home_user_id}"`;
|
|
}
|
|
}
|
|
|
|
if (!reason) continue;
|
|
|
|
// Winner selection
|
|
const aReal = a.password_hash !== '!federation-replicated' ? 1 : 0;
|
|
const bReal = b.password_hash !== '!federation-replicated' ? 1 : 0;
|
|
|
|
let winner: typeof a;
|
|
let loser: typeof a;
|
|
|
|
if (aReal !== bReal) {
|
|
winner = aReal > bReal ? a : b;
|
|
loser = aReal > bReal ? b : a;
|
|
} else {
|
|
const profileCount = (u: typeof a) =>
|
|
[u.display_name, u.avatar, u.banner, u.bio].filter(Boolean).length;
|
|
const aCount = profileCount(a);
|
|
const bCount = profileCount(b);
|
|
if (aCount !== bCount) {
|
|
winner = aCount > bCount ? a : b;
|
|
loser = aCount > bCount ? b : a;
|
|
} else {
|
|
winner = a.id < b.id ? a : b;
|
|
loser = a.id < b.id ? b : a;
|
|
}
|
|
}
|
|
|
|
// Check we haven't already scheduled either user in a merge
|
|
const alreadyScheduled = mergePairs.some(
|
|
p => p.winner.id === winner.id || p.winner.id === loser.id ||
|
|
p.loser.id === winner.id || p.loser.id === loser.id
|
|
);
|
|
if (!alreadyScheduled) {
|
|
mergePairs.push({ winner, loser, reason });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute merges — each pair in its own transaction for atomicity
|
|
const mergeOne = db.transaction((pair: MergePair) => {
|
|
const { winner, loser, reason } = pair;
|
|
let migratedDmMembers = 0;
|
|
let migratedDmMessages = 0;
|
|
let migratedDmReactions = 0;
|
|
let migratedFriends = 0;
|
|
let migratedFriendRequests = 0;
|
|
let migratedDmChannels = 0;
|
|
|
|
// Step 1: Enrich winner with loser's non-null profile fields
|
|
const enrichUpdates: Record<string, string | null> = {};
|
|
if (loser.display_name && !winner.display_name) enrichUpdates.display_name = loser.display_name;
|
|
if (loser.avatar && !winner.avatar) enrichUpdates.avatar = loser.avatar;
|
|
if (loser.avatar_color && !winner.avatar_color) enrichUpdates.avatar_color = loser.avatar_color;
|
|
if (loser.banner && !winner.banner) enrichUpdates.banner = loser.banner;
|
|
if (loser.bio && !winner.bio) enrichUpdates.bio = loser.bio;
|
|
|
|
// Prefer snowflake-format homeUserId (purely numeric) over username-format
|
|
const isNumeric = (s: string | null) => s !== null && /^\d+$/.test(s);
|
|
if (!winner.home_user_id && loser.home_user_id) {
|
|
enrichUpdates.home_user_id = loser.home_user_id;
|
|
} else if (winner.home_user_id && loser.home_user_id &&
|
|
!isNumeric(winner.home_user_id) && isNumeric(loser.home_user_id)) {
|
|
enrichUpdates.home_user_id = loser.home_user_id;
|
|
}
|
|
|
|
if (Object.keys(enrichUpdates).length > 0) {
|
|
const setClauses = Object.keys(enrichUpdates).map(k => `${k} = ?`).join(', ');
|
|
const values = [...Object.values(enrichUpdates), winner.id];
|
|
db.prepare(`UPDATE users SET ${setClauses} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
// Step 2: Re-point FK references — loser ID → winner ID
|
|
|
|
// dm_members: check for conflicts (winner already in the same channel)
|
|
// When both have membership, keep winner's row but set closed = MIN (if either had it open, keep open)
|
|
const loserMemberships = db.prepare(
|
|
`SELECT dm_channel_id, closed FROM dm_members WHERE user_id = ?`
|
|
).all(loser.id) as Array<{ dm_channel_id: string; closed: number }>;
|
|
|
|
for (const m of loserMemberships) {
|
|
const winnerMembership = db.prepare(
|
|
`SELECT closed FROM dm_members WHERE dm_channel_id = ? AND user_id = ?`
|
|
).get(m.dm_channel_id, winner.id) as { closed: number } | undefined;
|
|
if (winnerMembership) {
|
|
const mergedClosed = Math.min(winnerMembership.closed, m.closed);
|
|
if (mergedClosed !== winnerMembership.closed) {
|
|
db.prepare(`UPDATE dm_members SET closed = ? WHERE dm_channel_id = ? AND user_id = ?`).run(mergedClosed, m.dm_channel_id, winner.id);
|
|
}
|
|
db.prepare(`DELETE FROM dm_members WHERE dm_channel_id = ? AND user_id = ?`).run(m.dm_channel_id, loser.id);
|
|
} else {
|
|
db.prepare(`UPDATE dm_members SET user_id = ? WHERE dm_channel_id = ? AND user_id = ?`).run(winner.id, m.dm_channel_id, loser.id);
|
|
}
|
|
migratedDmMembers++;
|
|
}
|
|
|
|
// dm_messages: no uniqueness constraint — safe to update all
|
|
const msgResult = db.prepare(`UPDATE dm_messages SET user_id = ? WHERE user_id = ?`).run(winner.id, loser.id);
|
|
migratedDmMessages = msgResult.changes;
|
|
|
|
// dm_reactions: check for conflicts (winner already has same reaction on same message)
|
|
const loserReactions = db.prepare(
|
|
`SELECT id, dm_message_id, emoji FROM dm_reactions WHERE user_id = ?`
|
|
).all(loser.id) as Array<{ id: string; dm_message_id: string; emoji: string }>;
|
|
|
|
for (const r of loserReactions) {
|
|
const winnerAlreadyReacted = db.prepare(
|
|
`SELECT 1 FROM dm_reactions WHERE dm_message_id = ? AND user_id = ? AND emoji = ?`
|
|
).get(r.dm_message_id, winner.id, r.emoji);
|
|
if (winnerAlreadyReacted) {
|
|
db.prepare(`DELETE FROM dm_reactions WHERE id = ?`).run(r.id);
|
|
} else {
|
|
db.prepare(`UPDATE dm_reactions SET user_id = ? WHERE id = ?`).run(winner.id, r.id);
|
|
}
|
|
migratedDmReactions++;
|
|
}
|
|
|
|
// friends: check for conflicts
|
|
const loserFriendships = db.prepare(
|
|
`SELECT user_id, friend_id FROM friends WHERE user_id = ? OR friend_id = ?`
|
|
).all(loser.id, loser.id) as Array<{ user_id: string; friend_id: string }>;
|
|
|
|
for (const f of loserFriendships) {
|
|
const newUserId = f.user_id === loser.id ? winner.id : f.user_id;
|
|
const newFriendId = f.friend_id === loser.id ? winner.id : f.friend_id;
|
|
|
|
// Skip self-friendships that would result from merge
|
|
if (newUserId === newFriendId) {
|
|
db.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id = ?`).run(f.user_id, f.friend_id);
|
|
migratedFriends++;
|
|
continue;
|
|
}
|
|
|
|
const alreadyExists = db.prepare(
|
|
`SELECT 1 FROM friends WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)`
|
|
).get(newUserId, newFriendId, newFriendId, newUserId);
|
|
if (alreadyExists) {
|
|
db.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id = ?`).run(f.user_id, f.friend_id);
|
|
} else {
|
|
if (f.user_id === loser.id) {
|
|
db.prepare(`UPDATE friends SET user_id = ? WHERE user_id = ? AND friend_id = ?`).run(winner.id, loser.id, f.friend_id);
|
|
} else {
|
|
db.prepare(`UPDATE friends SET friend_id = ? WHERE user_id = ? AND friend_id = ?`).run(winner.id, f.user_id, loser.id);
|
|
}
|
|
}
|
|
migratedFriends++;
|
|
}
|
|
|
|
// friend_requests: check for conflicts
|
|
const loserRequests = db.prepare(
|
|
`SELECT id, from_id, to_id FROM friend_requests WHERE from_id = ? OR to_id = ?`
|
|
).all(loser.id, loser.id) as Array<{ id: string; from_id: string; to_id: string }>;
|
|
|
|
for (const r of loserRequests) {
|
|
const newFromId = r.from_id === loser.id ? winner.id : r.from_id;
|
|
const newToId = r.to_id === loser.id ? winner.id : r.to_id;
|
|
|
|
if (newFromId === newToId) {
|
|
db.prepare(`DELETE FROM friend_requests WHERE id = ?`).run(r.id);
|
|
migratedFriendRequests++;
|
|
continue;
|
|
}
|
|
|
|
const alreadyExists = db.prepare(
|
|
`SELECT 1 FROM friend_requests WHERE from_id = ? AND to_id = ?`
|
|
).get(newFromId, newToId);
|
|
if (alreadyExists) {
|
|
db.prepare(`DELETE FROM friend_requests WHERE id = ?`).run(r.id);
|
|
} else {
|
|
if (r.from_id === loser.id) {
|
|
db.prepare(`UPDATE friend_requests SET from_id = ? WHERE id = ?`).run(winner.id, r.id);
|
|
}
|
|
if (r.to_id === loser.id) {
|
|
db.prepare(`UPDATE friend_requests SET to_id = ? WHERE id = ?`).run(winner.id, r.id);
|
|
}
|
|
}
|
|
migratedFriendRequests++;
|
|
}
|
|
|
|
// dm_channels: update owner_id
|
|
const ownerResult = db.prepare(`UPDATE dm_channels SET owner_id = ? WHERE owner_id = ?`).run(winner.id, loser.id);
|
|
migratedDmChannels = ownerResult.changes;
|
|
|
|
// read_states: conflict handling — keep the row with more recent updated_at
|
|
let migratedReadStates = 0;
|
|
const loserReadStates = db.prepare(
|
|
`SELECT user_id, channel_id, last_read_message_id, updated_at FROM read_states WHERE user_id = ?`
|
|
).all(loser.id) as Array<{ user_id: string; channel_id: string; last_read_message_id: string; updated_at: number }>;
|
|
|
|
for (const rs of loserReadStates) {
|
|
const winnerRs = db.prepare(
|
|
`SELECT updated_at FROM read_states WHERE user_id = ? AND channel_id = ?`
|
|
).get(winner.id, rs.channel_id) as { updated_at: number } | undefined;
|
|
if (winnerRs) {
|
|
if (rs.updated_at > winnerRs.updated_at) {
|
|
db.prepare(`UPDATE read_states SET last_read_message_id = ?, updated_at = ? WHERE user_id = ? AND channel_id = ?`)
|
|
.run(rs.last_read_message_id, rs.updated_at, winner.id, rs.channel_id);
|
|
}
|
|
db.prepare(`DELETE FROM read_states WHERE user_id = ? AND channel_id = ?`).run(loser.id, rs.channel_id);
|
|
} else {
|
|
db.prepare(`UPDATE read_states SET user_id = ? WHERE user_id = ? AND channel_id = ?`).run(winner.id, loser.id, rs.channel_id);
|
|
}
|
|
migratedReadStates++;
|
|
}
|
|
|
|
// Step 4: Soft-delete loser
|
|
db.prepare(`UPDATE users SET is_deleted = 1 WHERE id = ?`).run(loser.id);
|
|
|
|
console.log(`[migration] Merged duplicate user stubs for domain ${winner.home_instance}:`);
|
|
console.log(` Winner: ${winner.id} (${winner.username}) — kept`);
|
|
console.log(` Loser: ${loser.id} (${loser.username}) — soft-deleted`);
|
|
console.log(` Migrated: ${migratedDmMembers} dm_members, ${migratedDmMessages} dm_messages, ${migratedDmReactions} dm_reactions, ${migratedFriends} friends, ${migratedFriendRequests} friend_requests, ${migratedDmChannels} dm_channels, ${migratedReadStates} read_states`);
|
|
console.log(` Match reason: ${reason}`);
|
|
});
|
|
|
|
for (const pair of mergePairs) {
|
|
mergeOne(pair);
|
|
}
|
|
|
|
if (mergePairs.length > 0) {
|
|
console.log(`[migration] Merged ${mergePairs.length} duplicate user stub pair(s).`);
|
|
}
|
|
|
|
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') || tableInfo.sql.includes('REFERENCES "dm_messages"')) return;
|
|
|
|
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...');
|
|
|
|
// Detect all current columns so we don't drop federation columns added before this migration runs
|
|
const columns = db.pragma('table_info(dm_messages)') as { name: string }[];
|
|
const colNames = columns.map(c => c.name);
|
|
|
|
// Build the new table with all existing columns, adding FK to reply_to_id
|
|
const colDefs: string[] = [];
|
|
for (const col of colNames) {
|
|
switch (col) {
|
|
case 'id': colDefs.push('id TEXT PRIMARY KEY'); break;
|
|
case 'dm_channel_id': colDefs.push('dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE'); break;
|
|
case 'user_id': colDefs.push('user_id TEXT NOT NULL REFERENCES users(id)'); break;
|
|
case 'reply_to_id': colDefs.push('reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL'); break;
|
|
case 'content': colDefs.push('content TEXT'); break;
|
|
case 'edited_at': colDefs.push('edited_at INTEGER'); break;
|
|
case 'created_at': colDefs.push('created_at INTEGER NOT NULL'); break;
|
|
case 'source_instance': colDefs.push('source_instance TEXT'); break;
|
|
case 'source_message_id': colDefs.push('source_message_id TEXT'); break;
|
|
case 'encryption_version': colDefs.push('encryption_version INTEGER DEFAULT 0'); break;
|
|
default: colDefs.push(`${col} TEXT`); break;
|
|
}
|
|
}
|
|
|
|
const colList = colNames.join(', ');
|
|
db.exec(`
|
|
CREATE TABLE dm_messages_new (${colDefs.join(', ')});
|
|
INSERT INTO dm_messages_new SELECT ${colList} 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);
|
|
`);
|
|
}
|
|
|
|
/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */
|
|
function migrateRenameGifApiKey(db: Database.Database): void {
|
|
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
|
const hasTenor = cols.some(c => c.name === 'tenor_api_key');
|
|
const hasGif = cols.some(c => c.name === 'gif_api_key');
|
|
|
|
if (hasTenor && !hasGif) {
|
|
// Clean case: rename the old column
|
|
console.log('Migrating: Renaming tenor_api_key → gif_api_key in instance_settings');
|
|
db.exec('ALTER TABLE instance_settings RENAME COLUMN tenor_api_key TO gif_api_key');
|
|
} else if (hasTenor && hasGif) {
|
|
// Race condition: column-add loop created empty gif_api_key before rename could run.
|
|
// Copy the real key from tenor_api_key if gif_api_key is still NULL/empty.
|
|
const row = db.prepare('SELECT tenor_api_key, gif_api_key FROM instance_settings WHERE id = 1').get() as
|
|
{ tenor_api_key: string | null; gif_api_key: string | null } | undefined;
|
|
if (row && row.tenor_api_key && !row.gif_api_key) {
|
|
db.prepare('UPDATE instance_settings SET gif_api_key = ? WHERE id = 1').run(row.tenor_api_key);
|
|
console.log('Migrating: Copied API key from tenor_api_key → gif_api_key (fixing race condition)');
|
|
}
|
|
} else if (!hasTenor && !hasGif) {
|
|
// Fresh install or never had Tenor — just add the column
|
|
console.log('Migrating: Adding gif_api_key column to instance_settings');
|
|
db.exec('ALTER TABLE instance_settings ADD COLUMN gif_api_key TEXT');
|
|
}
|
|
// !hasTenor && hasGif → already correct, no-op
|
|
}
|
|
|
|
/** 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 }[];
|
|
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
|
|
|
for (const role of roles) {
|
|
// Skip if already a valid decimal string
|
|
try { BigInt(role.permissions); continue; } catch {}
|
|
|
|
// Try legacy JSON array
|
|
try {
|
|
const parsed = JSON.parse(role.permissions);
|
|
if (Array.isArray(parsed)) {
|
|
let result = 0n;
|
|
for (const key of parsed) {
|
|
const bit = PermissionBits[key as keyof typeof PermissionBits];
|
|
if (bit !== undefined) result |= bit;
|
|
}
|
|
update.run(result.toString(), role.id);
|
|
console.log(`Migrating: Converted legacy permissions for role ${role.id}`);
|
|
continue;
|
|
}
|
|
} catch { /* not JSON either */ }
|
|
|
|
// Unrecognized format — set to 0
|
|
update.run('0', role.id);
|
|
console.log(`Migrating: Reset unrecognized permissions for role ${role.id}`);
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the USE_VOICE_ACTIVITY bit (was bit 25) and shift STREAM (26→25)
|
|
* and DISCONNECT_MEMBERS (27→26) down.
|
|
*
|
|
* Gated behind a persistent `voice_bit_migrated` flag in instance_settings
|
|
* because the old and new bit positions overlap (STREAM moved into the same
|
|
* bit 25 that USE_VOICE_ACTIVITY occupied), making bit-inspection unreliable
|
|
* as an idempotency check. The previous version of this function had exactly
|
|
* that bug — it re-ran on every startup and silently stripped STREAM and
|
|
* DISCONNECT_MEMBERS from every role.
|
|
*
|
|
* On first run with the flag: repairs @everyone roles by re-adding STREAM,
|
|
* then sets the flag so it never runs again.
|
|
*/
|
|
function migrateRemoveVoiceActivityBit(db: Database.Database): void {
|
|
// Ensure the flag column exists
|
|
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
|
if (!cols.some(c => c.name === 'voice_bit_migrated')) {
|
|
db.exec('ALTER TABLE instance_settings ADD COLUMN voice_bit_migrated INTEGER DEFAULT 0');
|
|
}
|
|
|
|
// Check if already migrated
|
|
const row = db.prepare('SELECT voice_bit_migrated FROM instance_settings WHERE id = 1').get() as
|
|
{ voice_bit_migrated: number } | undefined;
|
|
if (row && row.voice_bit_migrated === 1) return;
|
|
|
|
// The bit-shifting migration already ran (possibly many times) via the old
|
|
// broken code. All roles are already on the new layout (STREAM=25,
|
|
// DISCONNECT_MEMBERS=26). The damage is that repeated re-runs wiped those
|
|
// bits. Repair what we can:
|
|
|
|
const STREAM_BIT = 1n << 25n;
|
|
const updateRole = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
|
|
|
// Repair @everyone roles: re-add STREAM where it's missing.
|
|
// @everyone role id === space id, so join on that.
|
|
const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[];
|
|
for (const space of spaces) {
|
|
const role = db.prepare('SELECT id, permissions FROM roles WHERE id = ?').get(space.id) as
|
|
{ id: string; permissions: string } | undefined;
|
|
if (!role?.permissions) continue;
|
|
try {
|
|
const perms = BigInt(role.permissions);
|
|
if ((perms & STREAM_BIT) === 0n) {
|
|
updateRole.run((perms | STREAM_BIT).toString(), role.id);
|
|
console.log(`Repair: Re-added STREAM to @everyone role for space ${space.id}`);
|
|
}
|
|
} catch { /* skip invalid */ }
|
|
}
|
|
|
|
// For non-@everyone roles, warn about potentially lost bits so admins can
|
|
// manually re-enable STREAM / DISCONNECT_MEMBERS if needed.
|
|
const customRoles = db.prepare(
|
|
'SELECT id, space_id, name, permissions FROM roles WHERE id NOT IN (SELECT id FROM spaces) AND permissions IS NOT NULL'
|
|
).all() as { id: string; space_id: string; name: string; permissions: string }[];
|
|
|
|
let warnCount = 0;
|
|
for (const role of customRoles) {
|
|
try {
|
|
const perms = BigInt(role.permissions);
|
|
if ((perms & STREAM_BIT) === 0n) {
|
|
warnCount++;
|
|
}
|
|
} catch { /* skip invalid */ }
|
|
}
|
|
if (warnCount > 0) {
|
|
console.log(
|
|
`Repair: ${warnCount} custom role(s) may be missing STREAM/DISCONNECT_MEMBERS permissions ` +
|
|
`due to a previous migration bug. Admins can re-enable these in Space Settings → Roles.`
|
|
);
|
|
}
|
|
|
|
// Set flag so this never runs again
|
|
db.prepare('UPDATE instance_settings SET voice_bit_migrated = 1 WHERE id = 1').run();
|
|
console.log('Migrating: Voice permission bit migration flagged as complete.');
|
|
}
|
|
|
|
/** 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 already-tombstoned users so their original username can be reused */
|
|
function migrateDeletedUsernames(db: Database.Database): void {
|
|
const rows = db.prepare(
|
|
"SELECT id, username FROM users WHERE is_deleted = 1 AND username NOT LIKE '!deleted:%'"
|
|
).all() as { id: string; username: string }[];
|
|
|
|
if (rows.length === 0) return;
|
|
|
|
const update = db.prepare('UPDATE users SET username = ? WHERE id = ?');
|
|
for (const row of rows) {
|
|
update.run(`!deleted:${row.id}`, row.id);
|
|
console.log(`Migrating: Freed username "${row.username}" from deleted user ${row.id}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* 2. DM attachments/reactions referencing non-existent dm_messages
|
|
* 3. Read states referencing non-existent channels
|
|
* 4. Stale moderator references (bans.banned_by, voice_restrictions.moderator_id, join_requests.decided_by)
|
|
*/
|
|
function migrateOrphanedData(db: Database.Database): void {
|
|
// 1. Delete DM channels with zero members (cascade cleans dm_messages)
|
|
const orphanedDms = db.prepare(`
|
|
SELECT dc.id FROM dm_channels dc
|
|
WHERE NOT EXISTS (SELECT 1 FROM dm_members dm WHERE dm.dm_channel_id = dc.id)
|
|
`).all() as { id: string }[];
|
|
|
|
if (orphanedDms.length > 0) {
|
|
const deleteAttachments = db.prepare(
|
|
'DELETE FROM attachments WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id = ?)'
|
|
);
|
|
const deleteReactions = db.prepare(
|
|
'DELETE FROM dm_reactions WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id = ?)'
|
|
);
|
|
const deleteDmChannel = db.prepare('DELETE FROM dm_channels WHERE id = ?');
|
|
|
|
for (const { id } of orphanedDms) {
|
|
deleteAttachments.run(id);
|
|
deleteReactions.run(id);
|
|
deleteDmChannel.run(id);
|
|
}
|
|
console.log(`Migrating: Cleaned up ${orphanedDms.length} orphaned DM channels`);
|
|
}
|
|
|
|
// 2. Delete orphaned DM attachments referencing non-existent dm_messages
|
|
const orphanedAtts = db.prepare(`
|
|
DELETE FROM attachments
|
|
WHERE dm_message_id IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM dm_messages WHERE dm_messages.id = attachments.dm_message_id)
|
|
`).run();
|
|
if (orphanedAtts.changes > 0) {
|
|
console.log(`Migrating: Cleaned up ${orphanedAtts.changes} orphaned DM attachments`);
|
|
}
|
|
|
|
// 3. Delete orphaned DM reactions referencing non-existent dm_messages
|
|
const orphanedReactions = db.prepare(`
|
|
DELETE FROM dm_reactions
|
|
WHERE NOT EXISTS (SELECT 1 FROM dm_messages WHERE dm_messages.id = dm_reactions.dm_message_id)
|
|
`).run();
|
|
if (orphanedReactions.changes > 0) {
|
|
console.log(`Migrating: Cleaned up ${orphanedReactions.changes} orphaned DM reactions`);
|
|
}
|
|
|
|
// 4. Delete orphaned read_states referencing non-existent channels (or DM channels)
|
|
const orphanedReadStates = db.prepare(`
|
|
DELETE FROM read_states
|
|
WHERE NOT EXISTS (SELECT 1 FROM channels WHERE channels.id = read_states.channel_id)
|
|
AND NOT EXISTS (SELECT 1 FROM dm_channels WHERE dm_channels.id = read_states.channel_id)
|
|
`).run();
|
|
if (orphanedReadStates.changes > 0) {
|
|
console.log(`Migrating: Cleaned up ${orphanedReadStates.changes} orphaned read_states`);
|
|
}
|
|
|
|
// 5. Nullify stale moderator references pointing to deleted users
|
|
try {
|
|
const staleBans = db.prepare(`
|
|
UPDATE bans SET banned_by = NULL
|
|
WHERE banned_by IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = bans.banned_by AND users.is_deleted = 0)
|
|
`).run();
|
|
if (staleBans.changes > 0) {
|
|
console.log(`Migrating: Nullified ${staleBans.changes} stale bans.banned_by references`);
|
|
}
|
|
} catch { /* bans table may not exist yet */ }
|
|
|
|
try {
|
|
const staleVoice = db.prepare(`
|
|
UPDATE voice_restrictions SET moderator_id = NULL
|
|
WHERE moderator_id IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = voice_restrictions.moderator_id AND users.is_deleted = 0)
|
|
`).run();
|
|
if (staleVoice.changes > 0) {
|
|
console.log(`Migrating: Nullified ${staleVoice.changes} stale voice_restrictions.moderator_id references`);
|
|
}
|
|
} catch { /* voice_restrictions table may not exist yet */ }
|
|
|
|
try {
|
|
const staleJoinReqs = db.prepare(`
|
|
UPDATE join_requests SET decided_by = NULL
|
|
WHERE decided_by IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE users.id = join_requests.decided_by AND users.is_deleted = 0)
|
|
`).run();
|
|
if (staleJoinReqs.changes > 0) {
|
|
console.log(`Migrating: Nullified ${staleJoinReqs.changes} stale join_requests.decided_by references`);
|
|
}
|
|
} catch { /* join_requests table may not exist yet */ }
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/**
|
|
* Lowercase all existing native usernames. Skips federated users (contain @)
|
|
* and tombstoned users (!deleted: prefix). If lowercasing would cause a collision,
|
|
* skip that user to avoid data loss.
|
|
*/
|
|
function migrateLowercaseUsernames(db: Database.Database): void {
|
|
const rows = db.prepare(
|
|
"SELECT id, username FROM users WHERE username NOT LIKE '%@%' AND username NOT LIKE '!deleted:%'"
|
|
).all() as { id: string; username: string }[];
|
|
|
|
const needsUpdate = rows.filter(r => r.username !== r.username.toLowerCase());
|
|
if (needsUpdate.length === 0) return;
|
|
|
|
const checkExisting = db.prepare('SELECT id FROM users WHERE username = ?');
|
|
const update = db.prepare('UPDATE users SET username = ? WHERE id = ?');
|
|
|
|
for (const row of needsUpdate) {
|
|
const lower = row.username.toLowerCase();
|
|
// Check for collision (another user already has the lowercase version)
|
|
const existing = checkExisting.get(lower) as { id: string } | undefined;
|
|
if (existing && existing.id !== row.id) {
|
|
console.log(`Migrating: Skipping lowercase of "${row.username}" — "${lower}" already taken by user ${existing.id}`);
|
|
continue;
|
|
}
|
|
update.run(lower, row.id);
|
|
console.log(`Migrating: Lowercased username "${row.username}" → "${lower}"`);
|
|
}
|
|
}
|
|
|
|
/** Convert any existing video channels to voice (video type removed — voice channels have full video capability) */
|
|
function migrateVideoChannels(db: Database.Database): void {
|
|
const result = db.prepare("UPDATE channels SET type = 'voice' WHERE type = 'video'").run();
|
|
if (result.changes > 0) {
|
|
console.log(`Migrating: Converted ${result.changes} video channel(s) to voice`);
|
|
}
|
|
}
|
|
|
|
/** Backfill profile_updated_at from created_at for existing users */
|
|
function migrateProfileUpdatedAt(db: Database.Database): void {
|
|
const result = db.prepare(
|
|
'UPDATE users SET profile_updated_at = created_at WHERE profile_updated_at IS NULL'
|
|
).run();
|
|
if (result.changes > 0) {
|
|
console.log(`Migrating: Backfilled profile_updated_at for ${result.changes} user(s)`);
|
|
}
|
|
}
|
|
|
|
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}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up stale attachment records left behind by profile image uploads.
|
|
* Profile images (avatars, banners, space icons) go through POST /api/uploads
|
|
* but are referenced by users/spaces columns, not by attachments.message_id.
|
|
* This leaves orphaned attachment records that inflate the "Unlinked Uploads"
|
|
* count in the storage panel.
|
|
*
|
|
* Gated by a persistent flag so it runs exactly once.
|
|
*/
|
|
function migrateCleanupProfileAttachmentRecords(db: Database.Database): void {
|
|
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
|
if (!cols.some(c => c.name === 'profile_attachments_cleaned')) {
|
|
db.exec('ALTER TABLE instance_settings ADD COLUMN profile_attachments_cleaned INTEGER DEFAULT 0');
|
|
}
|
|
|
|
const row = db.prepare('SELECT profile_attachments_cleaned FROM instance_settings WHERE id = 1').get() as
|
|
{ profile_attachments_cleaned: number } | undefined;
|
|
if (row && row.profile_attachments_cleaned === 1) return;
|
|
|
|
// Collect all filenames currently referenced by profiles
|
|
const profileFilenames = new Set<string>();
|
|
|
|
const avatarRows = db.prepare('SELECT avatar FROM users WHERE avatar IS NOT NULL').all() as { avatar: string }[];
|
|
for (const r of avatarRows) profileFilenames.add(path.basename(r.avatar));
|
|
|
|
const bannerRows = db.prepare('SELECT banner FROM users WHERE banner IS NOT NULL').all() as { banner: string }[];
|
|
for (const r of bannerRows) profileFilenames.add(path.basename(r.banner));
|
|
|
|
const iconRows = db.prepare('SELECT icon FROM spaces WHERE icon IS NOT NULL').all() as { icon: string }[];
|
|
for (const r of iconRows) profileFilenames.add(path.basename(r.icon));
|
|
|
|
const spaceBannerRows = db.prepare('SELECT banner FROM spaces WHERE banner IS NOT NULL').all() as { banner: string }[];
|
|
for (const r of spaceBannerRows) profileFilenames.add(path.basename(r.banner));
|
|
|
|
// Find unlinked attachment records (no message reference)
|
|
const unlinkedRows = db.prepare(
|
|
'SELECT id, filename FROM attachments WHERE message_id IS NULL AND dm_message_id IS NULL'
|
|
).all() as { id: string; filename: string }[];
|
|
|
|
const deleteStmt = db.prepare('DELETE FROM attachments WHERE id = ?');
|
|
let cleaned = 0;
|
|
|
|
for (const att of unlinkedRows) {
|
|
const basename = path.basename(att.filename);
|
|
// Delete if the file is a current profile image (record is unnecessary)
|
|
// or if the file no longer exists on disk (stale record from a replaced profile image)
|
|
if (profileFilenames.has(basename)) {
|
|
deleteStmt.run(att.id);
|
|
cleaned++;
|
|
} else {
|
|
// Check if the file still exists on disk — if not, this is a stale
|
|
// record from a previously-replaced profile image whose file was
|
|
// already deleted by the PATCH handler
|
|
try {
|
|
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'data', 'uploads');
|
|
const filePath = path.join(uploadDir, basename);
|
|
if (!fs.existsSync(filePath)) {
|
|
deleteStmt.run(att.id);
|
|
cleaned++;
|
|
}
|
|
} catch {
|
|
// Skip on error — the normal cleanup can handle it later
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cleaned > 0) {
|
|
console.log(`Migrating: Cleaned up ${cleaned} stale profile image attachment record(s)`);
|
|
}
|
|
|
|
db.prepare('UPDATE instance_settings SET profile_attachments_cleaned = 1 WHERE id = 1').run();
|
|
}
|
|
|
|
function migrateCategoryOverrides(db: Database.Database): void {
|
|
const exists = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='category_overrides'"
|
|
).get();
|
|
if (exists) return;
|
|
|
|
console.log('Migrating: Adding category_overrides table...');
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS category_overrides (
|
|
category_id TEXT NOT NULL REFERENCES channel_categories(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 (category_id, target_type, target_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_category_overrides_category_id ON category_overrides(category_id);
|
|
`);
|
|
}
|
|
|
|
/** Add FK constraint to attachments.dm_message_id (SQLite requires table recreation) */
|
|
function migrateAttachmentsDmMessageFk(db: Database.Database): void {
|
|
const tableInfo = db.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='attachments'"
|
|
).get() as { sql: string } | undefined;
|
|
|
|
// Only migrate if dm_message_id exists but has no FK reference
|
|
if (!tableInfo) return;
|
|
if (!tableInfo.sql.includes('dm_message_id')) return;
|
|
if (tableInfo.sql.includes('REFERENCES dm_messages') || tableInfo.sql.includes('REFERENCES "dm_messages"')) return;
|
|
|
|
console.log('Migrating: Adding FK constraint to attachments.dm_message_id...');
|
|
|
|
// Detect all current columns so we don't drop federation columns added before this migration runs
|
|
const columns = db.pragma('table_info(attachments)') as { name: string }[];
|
|
const colNames = columns.map(c => c.name);
|
|
|
|
const colDefs: string[] = [];
|
|
for (const col of colNames) {
|
|
switch (col) {
|
|
case 'id': colDefs.push('id TEXT PRIMARY KEY'); break;
|
|
case 'message_id': colDefs.push('message_id TEXT REFERENCES messages(id) ON DELETE CASCADE'); break;
|
|
case 'dm_message_id': colDefs.push('dm_message_id TEXT REFERENCES dm_messages(id) ON DELETE CASCADE'); break;
|
|
case 'uploader_id': colDefs.push('uploader_id TEXT'); break;
|
|
case 'filename': colDefs.push('filename TEXT NOT NULL'); break;
|
|
case 'original_name': colDefs.push('original_name TEXT NOT NULL'); break;
|
|
case 'mimetype': colDefs.push('mimetype TEXT NOT NULL'); break;
|
|
case 'size': colDefs.push('size INTEGER NOT NULL'); break;
|
|
case 'thumbnail_filename': colDefs.push('thumbnail_filename TEXT'); break;
|
|
case 'width': colDefs.push('width INTEGER'); break;
|
|
case 'height': colDefs.push('height INTEGER'); break;
|
|
case 'duration': colDefs.push('duration REAL'); break;
|
|
case 'source_url': colDefs.push('source_url TEXT'); break;
|
|
case 'created_at': colDefs.push('created_at INTEGER NOT NULL'); break;
|
|
default: colDefs.push(`${col} TEXT`); break;
|
|
}
|
|
}
|
|
|
|
const colList = colNames.join(', ');
|
|
db.exec(`
|
|
CREATE TABLE attachments_new (${colDefs.join(', ')});
|
|
INSERT INTO attachments_new
|
|
SELECT ${colList}
|
|
FROM attachments
|
|
WHERE dm_message_id IS NULL
|
|
OR dm_message_id IN (SELECT id FROM dm_messages);
|
|
DROP TABLE attachments;
|
|
ALTER TABLE attachments_new RENAME TO 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);
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Async backfill: generate thumbnails for all existing image attachments that
|
|
* don't have one yet. Runs once after server startup, gated by a persistent
|
|
* flag in instance_settings so it never re-runs.
|
|
*
|
|
* Call this AFTER the server is listening — it's fire-and-forget and doesn't
|
|
* block startup.
|
|
*/
|
|
export async function backfillThumbnails(db: Database.Database, uploadDir: string): Promise<void> {
|
|
// Ensure the flag column exists
|
|
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
|
if (!cols.some(c => c.name === 'thumbnails_backfilled')) {
|
|
db.exec('ALTER TABLE instance_settings ADD COLUMN thumbnails_backfilled INTEGER DEFAULT 0');
|
|
}
|
|
|
|
const row = db.prepare('SELECT thumbnails_backfilled FROM instance_settings WHERE id = 1').get() as
|
|
{ thumbnails_backfilled: number } | undefined;
|
|
if (row && row.thumbnails_backfilled === 1) return;
|
|
|
|
// Find all image attachments without a thumbnail
|
|
const rows = db.prepare(
|
|
"SELECT id, filename, mimetype FROM attachments WHERE thumbnail_filename IS NULL"
|
|
).all() as { id: string; filename: string; mimetype: string }[];
|
|
|
|
const candidates = rows.filter(r => isResizableImage(r.mimetype));
|
|
if (candidates.length === 0) {
|
|
db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run();
|
|
return;
|
|
}
|
|
|
|
console.log(`Backfill: Generating thumbnails for ${candidates.length} existing image(s)...`);
|
|
|
|
const update = db.prepare('UPDATE attachments SET thumbnail_filename = ? WHERE id = ?');
|
|
let generated = 0;
|
|
let skipped = 0;
|
|
|
|
for (const att of candidates) {
|
|
const originalPath = path.join(uploadDir, path.basename(att.filename));
|
|
if (!fs.existsSync(originalPath)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const thumbName = await generateThumbnail(originalPath, att.mimetype, uploadDir);
|
|
if (thumbName) {
|
|
update.run(thumbName, att.id);
|
|
generated++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
console.log(`Backfill: Generated ${generated} thumbnail(s), skipped ${skipped} (small or missing)`);
|
|
db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run();
|
|
}
|
|
|
|
/**
|
|
* Async backfill: extract dimensions and generate thumbnails for existing
|
|
* video and image attachments that don't have width/height yet.
|
|
* Runs once after server startup, gated by a persistent flag.
|
|
*/
|
|
export async function backfillMediaDimensions(db: Database.Database, uploadDir: string): Promise<void> {
|
|
// Ensure the flag column exists
|
|
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
|
if (!cols.some(c => c.name === 'media_dimensions_backfilled')) {
|
|
db.exec('ALTER TABLE instance_settings ADD COLUMN media_dimensions_backfilled INTEGER DEFAULT 0');
|
|
}
|
|
|
|
const row = db.prepare('SELECT media_dimensions_backfilled FROM instance_settings WHERE id = 1').get() as
|
|
{ media_dimensions_backfilled: number } | undefined;
|
|
if (row && row.media_dimensions_backfilled === 1) return;
|
|
|
|
// Find all image/video attachments without dimensions
|
|
const rows = db.prepare(
|
|
"SELECT id, filename, mimetype FROM attachments WHERE width IS NULL AND (mimetype LIKE 'video/%' OR mimetype LIKE 'image/%')"
|
|
).all() as { id: string; filename: string; mimetype: string }[];
|
|
|
|
if (rows.length === 0) {
|
|
db.prepare('UPDATE instance_settings SET media_dimensions_backfilled = 1 WHERE id = 1').run();
|
|
return;
|
|
}
|
|
|
|
console.log(`Backfill: Extracting dimensions for ${rows.length} existing media attachment(s)...`);
|
|
|
|
const update = db.prepare(
|
|
'UPDATE attachments SET width = ?, height = ?, duration = ?, thumbnail_filename = COALESCE(?, thumbnail_filename) WHERE id = ?'
|
|
);
|
|
let processed = 0;
|
|
let skipped = 0;
|
|
|
|
for (const att of rows) {
|
|
const originalPath = path.join(uploadDir, path.basename(att.filename));
|
|
if (!fs.existsSync(originalPath)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (att.mimetype.startsWith('video/')) {
|
|
// Video: thumbnail + dimensions + duration
|
|
const videoThumb = await generateVideoThumbnail(originalPath, uploadDir);
|
|
const meta = await probeMediaMeta(originalPath, att.mimetype);
|
|
|
|
const width = videoThumb?.width ?? meta?.width ?? null;
|
|
const height = videoThumb?.height ?? meta?.height ?? null;
|
|
const duration = meta?.duration ?? null;
|
|
const thumbName = videoThumb?.thumbnailFilename ?? null;
|
|
|
|
update.run(width, height, duration, thumbName, att.id);
|
|
processed++;
|
|
} else {
|
|
// Image: dimensions only
|
|
const dims = await probeImageDimensions(originalPath);
|
|
if (dims) {
|
|
update.run(dims.width, dims.height, null, null, att.id);
|
|
processed++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`Backfill: Failed to process ${att.filename} (non-fatal):`, err);
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
console.log(`Backfill: Processed ${processed} media attachment(s), skipped ${skipped}`);
|
|
db.prepare('UPDATE instance_settings SET media_dimensions_backfilled = 1 WHERE id = 1').run();
|
|
}
|
|
|
|
/**
|
|
* Rename canonical_pair_id → federated_id in dm_channels and add the new
|
|
* group DM federation columns (owner_home_user_id, owner_home_instance, deleted_at).
|
|
*
|
|
* Handles three upgrade paths:
|
|
* 1. Existing install with canonical_pair_id column → full table rebuild to rename + add columns
|
|
* 2. Existing install without canonical_pair_id but missing new columns → ALTER TABLE adds them
|
|
* 3. Fresh install → DDL in index.ts already has the correct schema; this is a no-op
|
|
*/
|
|
function migrateDmChannelsFederatedId(db: Database.Database): void {
|
|
const cols = db.prepare(`PRAGMA table_info(dm_channels)`).all() as Array<{ name: string }>;
|
|
const hasOldCol = cols.some(c => c.name === 'canonical_pair_id');
|
|
const hasNewCol = cols.some(c => c.name === 'federated_id');
|
|
|
|
if (!hasOldCol && hasNewCol) {
|
|
// Already migrated — ensure auxiliary columns exist (handles partial migration states)
|
|
const colNames = new Set(cols.map(c => c.name));
|
|
if (!colNames.has('owner_home_user_id')) {
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_user_id TEXT`);
|
|
}
|
|
if (!colNames.has('owner_home_instance')) {
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_instance TEXT`);
|
|
}
|
|
if (!colNames.has('deleted_at')) {
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN deleted_at INTEGER`);
|
|
}
|
|
// Rebuild index in case it was dropped
|
|
db.exec(`DROP INDEX IF EXISTS idx_dm_canonical_pair`);
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`);
|
|
return;
|
|
}
|
|
|
|
if (hasOldCol && !hasNewCol) {
|
|
// Full table rebuild to rename column — disable FK enforcement during DROP
|
|
console.log('Migrating: Renaming canonical_pair_id → federated_id in dm_channels and adding group DM columns...');
|
|
db.exec(`PRAGMA foreign_keys = OFF`);
|
|
try {
|
|
db.transaction(() => {
|
|
db.exec(`
|
|
CREATE TABLE dm_channels_new (
|
|
id TEXT PRIMARY KEY,
|
|
owner_id TEXT,
|
|
federated_id TEXT,
|
|
owner_home_user_id TEXT,
|
|
owner_home_instance TEXT,
|
|
deleted_at INTEGER,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
INSERT INTO dm_channels_new (id, owner_id, federated_id, created_at)
|
|
SELECT id, owner_id, canonical_pair_id, created_at FROM dm_channels;
|
|
DROP TABLE dm_channels;
|
|
ALTER TABLE dm_channels_new RENAME TO dm_channels;
|
|
`);
|
|
})();
|
|
} finally {
|
|
db.exec(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
db.exec(`DROP INDEX IF EXISTS idx_dm_canonical_pair`);
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`);
|
|
console.log('Migrating: dm_channels rename complete.');
|
|
} else if (!hasOldCol && !hasNewCol) {
|
|
// Neither column exists — this is either a very old install or an odd state.
|
|
// Just add all the new columns via ALTER TABLE.
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN federated_id TEXT`);
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_user_id TEXT`);
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN owner_home_instance TEXT`);
|
|
db.exec(`ALTER TABLE dm_channels ADD COLUMN deleted_at INTEGER`);
|
|
db.exec(`DROP INDEX IF EXISTS idx_dm_canonical_pair`);
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dm_federated ON dm_channels(federated_id) WHERE federated_id IS NOT NULL`);
|
|
}
|
|
// hasOldCol && hasNewCol — both columns exist (shouldn't happen, but safe to skip)
|
|
|
|
// Backfill owner_home_user_id / owner_home_instance from existing group DMs
|
|
try {
|
|
const groupDms = db.prepare(`
|
|
SELECT dc.id, dc.owner_id, u.home_user_id, u.home_instance
|
|
FROM dm_channels dc
|
|
JOIN users u ON dc.owner_id = u.id
|
|
WHERE dc.owner_id IS NOT NULL
|
|
AND dc.owner_home_user_id IS NULL
|
|
`).all() as Array<{
|
|
id: string;
|
|
owner_id: string;
|
|
home_user_id: string | null;
|
|
home_instance: string | null;
|
|
}>;
|
|
|
|
if (groupDms.length > 0) {
|
|
const updateStmt = db.prepare(`
|
|
UPDATE dm_channels
|
|
SET owner_home_user_id = ?, owner_home_instance = ?
|
|
WHERE id = ?
|
|
`);
|
|
|
|
for (const gd of groupDms) {
|
|
updateStmt.run(
|
|
gd.home_user_id || gd.owner_id,
|
|
gd.home_instance || null,
|
|
gd.id,
|
|
);
|
|
}
|
|
|
|
console.log(`[migrate] Backfilled owner federation identity for ${groupDms.length} group DMs`);
|
|
}
|
|
} catch (err) {
|
|
console.error('migrateDmChannelsFederatedId: owner backfill failed (non-fatal):', err);
|
|
}
|
|
}
|
|
|
|
/** Fix ownerId on 1-on-1 DMs: should be NULL, not the creator's ID */
|
|
function migrateFixOneOnOneOwnerIds(db: Database.Database): void {
|
|
try {
|
|
const result = db.prepare(`
|
|
UPDATE dm_channels SET owner_id = NULL
|
|
WHERE id IN (
|
|
SELECT dm_channel_id FROM dm_members
|
|
GROUP BY dm_channel_id
|
|
HAVING COUNT(*) = 2
|
|
)
|
|
AND owner_id IS NOT NULL
|
|
AND (federated_id IS NULL OR length(federated_id) = 32)
|
|
`).run();
|
|
|
|
if (result.changes > 0) {
|
|
console.log(`[migrate] Fixed ownerId on ${result.changes} 1-on-1 DM channel(s) (set to NULL)`);
|
|
}
|
|
} catch (err) {
|
|
console.error('migrateFixOneOnOneOwnerIds failed (non-fatal):', err);
|
|
}
|
|
}
|
|
|
|
/** Reset federation sync checkpoint so legacy DMs get replicated via S2S */
|
|
function migrateResetFederationSyncForLegacyDms(db: Database.Database): void {
|
|
try {
|
|
const peersTable = db.prepare(`PRAGMA table_info(federation_peers)`).all() as Array<{ name: string }>;
|
|
if (peersTable.length === 0) return; // No federation tables yet
|
|
|
|
const hasSyncFlag = (db.pragma('table_info(instance_settings)') as Array<{ name: string }>)
|
|
.some(c => c.name === 'legacy_dm_sync_done');
|
|
|
|
if (!hasSyncFlag) {
|
|
db.exec(`ALTER TABLE instance_settings ADD COLUMN legacy_dm_sync_done INTEGER DEFAULT 0`);
|
|
}
|
|
|
|
const settings = db.prepare('SELECT legacy_dm_sync_done FROM instance_settings WHERE id = 1').get() as { legacy_dm_sync_done: number } | undefined;
|
|
if (settings?.legacy_dm_sync_done) return; // Already ran
|
|
|
|
const result = db.prepare(`UPDATE federation_peers SET last_synced_at = 0 WHERE status = 'active'`).run();
|
|
|
|
if (result.changes > 0) {
|
|
console.log(`[migrate] Reset sync checkpoint on ${result.changes} federation peer(s) for legacy DM replication`);
|
|
}
|
|
|
|
db.prepare('UPDATE instance_settings SET legacy_dm_sync_done = 1 WHERE id = 1').run();
|
|
} catch (err) {
|
|
console.error('migrateResetFederationSyncForLegacyDms failed (non-fatal):', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generalize federation_outbox and federation_mutation_log column names.
|
|
* Renames dm_channel_id → context_id, message_id → entity_id in federation_outbox,
|
|
* and dm_message_id → entity_id, dm_channel_id → context_id in federation_mutation_log.
|
|
* Adds context_type = 'dm' for all existing rows.
|
|
*/
|
|
function migrateGeneralizeOutbox(db: Database.Database): void {
|
|
// --- federation_outbox ---
|
|
const outboxCols = db.prepare(`PRAGMA table_info(federation_outbox)`).all() as Array<{ name: string }>;
|
|
const outboxColNames = new Set(outboxCols.map(c => c.name));
|
|
|
|
if (outboxColNames.has('context_id')) {
|
|
if (!outboxColNames.has('context_type')) {
|
|
db.exec(`ALTER TABLE federation_outbox ADD COLUMN context_type TEXT NOT NULL DEFAULT 'dm'`);
|
|
}
|
|
} else if (outboxColNames.has('dm_channel_id')) {
|
|
console.log('Migrating: Generalizing federation_outbox columns (dm_channel_id → context_id, message_id → entity_id)...');
|
|
db.exec(`PRAGMA foreign_keys = OFF`);
|
|
try {
|
|
db.transaction(() => {
|
|
db.exec(`
|
|
CREATE TABLE federation_outbox_new (
|
|
id TEXT PRIMARY KEY,
|
|
peer_id TEXT NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE,
|
|
context_id TEXT NOT NULL,
|
|
entity_id TEXT NOT NULL,
|
|
context_type TEXT NOT NULL DEFAULT 'dm',
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
encryption_version INTEGER DEFAULT 0,
|
|
attempts INTEGER DEFAULT 0,
|
|
next_retry_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
UNIQUE(peer_id, entity_id)
|
|
);
|
|
INSERT INTO federation_outbox_new (id, peer_id, context_id, entity_id, context_type, event_type, payload, encryption_version, attempts, next_retry_at, expires_at, created_at)
|
|
SELECT id, peer_id, dm_channel_id, message_id, 'dm', event_type, payload, encryption_version, attempts, next_retry_at, expires_at, created_at
|
|
FROM federation_outbox;
|
|
DROP TABLE federation_outbox;
|
|
ALTER TABLE federation_outbox_new RENAME TO federation_outbox;
|
|
`);
|
|
})();
|
|
} finally {
|
|
db.exec(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
}
|
|
|
|
// --- federation_mutation_log ---
|
|
const logCols = db.prepare(`PRAGMA table_info(federation_mutation_log)`).all() as Array<{ name: string }>;
|
|
const logColNames = new Set(logCols.map(c => c.name));
|
|
|
|
if (logColNames.has('entity_id')) {
|
|
if (!logColNames.has('context_type')) {
|
|
db.exec(`ALTER TABLE federation_mutation_log ADD COLUMN context_type TEXT NOT NULL DEFAULT 'dm'`);
|
|
}
|
|
} else if (logColNames.has('dm_message_id')) {
|
|
console.log('Migrating: Generalizing federation_mutation_log columns (dm_message_id → entity_id, dm_channel_id → context_id)...');
|
|
db.exec(`PRAGMA foreign_keys = OFF`);
|
|
try {
|
|
db.transaction(() => {
|
|
db.exec(`
|
|
CREATE TABLE federation_mutation_log_new (
|
|
id TEXT PRIMARY KEY,
|
|
entity_id TEXT NOT NULL,
|
|
context_id TEXT NOT NULL,
|
|
context_type TEXT NOT NULL DEFAULT 'dm',
|
|
mutation_type TEXT NOT NULL,
|
|
mutated_at INTEGER NOT NULL,
|
|
payload TEXT
|
|
);
|
|
INSERT INTO federation_mutation_log_new (id, entity_id, context_id, context_type, mutation_type, mutated_at, payload)
|
|
SELECT id, dm_message_id, dm_channel_id, 'dm', mutation_type, mutated_at, payload
|
|
FROM federation_mutation_log;
|
|
DROP TABLE federation_mutation_log;
|
|
ALTER TABLE federation_mutation_log_new RENAME TO federation_mutation_log;
|
|
`);
|
|
})();
|
|
} finally {
|
|
db.exec(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
}
|
|
|
|
// ─── FED-008: Add nonceSupported column for replay attack protection ───────
|
|
try {
|
|
db.exec(`ALTER TABLE federation_peers ADD COLUMN nonce_supported INTEGER NOT NULL DEFAULT 0`);
|
|
} catch { /* column already exists */ }
|
|
}
|