Files
Toju/electron/database.js
2026-03-02 03:30:22 +01:00

619 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Electron main-process SQLite database module.
*
* All SQL queries live here the renderer communicates exclusively via IPC.
* Uses sql.js (WASM SQLite) loaded in Node.js.
*/
const { ipcMain, app } = require('electron');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
let db = null;
let dbPath = '';
/* ------------------------------------------------------------------ */
/* Migrations */
/* ------------------------------------------------------------------ */
const migrations = [
{
version: 1,
description: 'Initial schema messages, users, rooms, reactions, bans, meta',
up(database) {
database.run(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
roomId TEXT NOT NULL,
channelId TEXT,
senderId TEXT NOT NULL,
senderName TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
editedAt INTEGER,
reactions TEXT NOT NULL DEFAULT '[]',
isDeleted INTEGER NOT NULL DEFAULT 0,
replyToId TEXT
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_messages_roomId ON messages(roomId);');
database.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
oderId TEXT,
username TEXT,
displayName TEXT,
avatarUrl TEXT,
status TEXT,
role TEXT,
joinedAt INTEGER,
peerId TEXT,
isOnline INTEGER,
isAdmin INTEGER,
isRoomOwner INTEGER,
voiceState TEXT,
screenShareState TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
topic TEXT,
hostId TEXT NOT NULL,
password TEXT,
isPrivate INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL,
userCount INTEGER NOT NULL DEFAULT 0,
maxUsers INTEGER,
icon TEXT,
iconUpdatedAt INTEGER,
permissions TEXT,
channels TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS reactions (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL,
oderId TEXT,
userId TEXT,
emoji TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_reactions_messageId ON reactions(messageId);');
database.run(`
CREATE TABLE IF NOT EXISTS bans (
oderId TEXT NOT NULL,
userId TEXT,
roomId TEXT NOT NULL,
bannedBy TEXT NOT NULL,
displayName TEXT,
reason TEXT,
expiresAt INTEGER,
timestamp INTEGER NOT NULL,
PRIMARY KEY (oderId, roomId)
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_bans_roomId ON bans(roomId);');
database.run(`
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
},
},
{
version: 2,
description: 'Attachments table',
up(database) {
database.run(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
isImage INTEGER NOT NULL DEFAULT 0,
uploaderPeerId TEXT,
filePath TEXT,
savedPath TEXT
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_attachments_messageId ON attachments(messageId);');
},
},
];
function runMigrations() {
db.run(`
CREATE TABLE IF NOT EXISTS schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL DEFAULT 0
);
`);
const row = db.exec('SELECT version FROM schema_version WHERE id = 1');
let currentVersion = row.length > 0 ? row[0].values[0][0] : 0;
if (row.length === 0) {
db.run('INSERT INTO schema_version (id, version) VALUES (1, 0)');
}
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`[ElectronDB] Running migration v${migration.version}: ${migration.description}`);
migration.up(db);
currentVersion = migration.version;
db.run('UPDATE schema_version SET version = ? WHERE id = 1', [currentVersion]);
}
}
}
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
function persist() {
if (!db) return;
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
}
/* ------------------------------------------------------------------ */
/* Initialisation */
/* ------------------------------------------------------------------ */
async function initDatabase() {
const initSqlJs = require('sql.js');
const SQL = await initSqlJs();
const dbDir = path.join(app.getPath('userData'), 'metoyou');
await fsp.mkdir(dbDir, { recursive: true });
dbPath = path.join(dbDir, 'metoyou.sqlite');
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
db = new SQL.Database(fileBuffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA journal_mode = MEMORY;');
db.run('PRAGMA synchronous = NORMAL;');
runMigrations();
persist();
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Run a prepared-statement query and return rows as plain objects. */
function queryAll(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
const results = [];
while (stmt.step()) results.push(stmt.getAsObject());
stmt.free();
return results;
}
/** Return a single row as object or null. */
function queryOne(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
let result = null;
if (stmt.step()) result = stmt.getAsObject();
stmt.free();
return result;
}
/* ------------------------------------------------------------------ */
/* Row → model mappers */
/* ------------------------------------------------------------------ */
function rowToMessage(r) {
return {
id: String(r.id),
roomId: String(r.roomId),
channelId: r.channelId ? String(r.channelId) : undefined,
senderId: String(r.senderId),
senderName: String(r.senderName),
content: String(r.content),
timestamp: Number(r.timestamp),
editedAt: r.editedAt != null ? Number(r.editedAt) : undefined,
reactions: JSON.parse(String(r.reactions || '[]')),
isDeleted: !!r.isDeleted,
replyToId: r.replyToId ? String(r.replyToId) : undefined,
};
}
function rowToUser(r) {
return {
id: String(r.id),
oderId: String(r.oderId ?? ''),
username: String(r.username ?? ''),
displayName: String(r.displayName ?? ''),
avatarUrl: r.avatarUrl ? String(r.avatarUrl) : undefined,
status: String(r.status ?? 'offline'),
role: String(r.role ?? 'member'),
joinedAt: Number(r.joinedAt ?? 0),
peerId: r.peerId ? String(r.peerId) : undefined,
isOnline: !!r.isOnline,
isAdmin: !!r.isAdmin,
isRoomOwner: !!r.isRoomOwner,
voiceState: r.voiceState ? JSON.parse(String(r.voiceState)) : undefined,
screenShareState: r.screenShareState ? JSON.parse(String(r.screenShareState)) : undefined,
};
}
function rowToRoom(r) {
return {
id: String(r.id),
name: String(r.name),
description: r.description ? String(r.description) : undefined,
topic: r.topic ? String(r.topic) : undefined,
hostId: String(r.hostId),
password: r.password ? String(r.password) : undefined,
isPrivate: !!r.isPrivate,
createdAt: Number(r.createdAt),
userCount: Number(r.userCount),
maxUsers: r.maxUsers != null ? Number(r.maxUsers) : undefined,
icon: r.icon ? String(r.icon) : undefined,
iconUpdatedAt: r.iconUpdatedAt != null ? Number(r.iconUpdatedAt) : undefined,
permissions: r.permissions ? JSON.parse(String(r.permissions)) : undefined,
channels: r.channels ? JSON.parse(String(r.channels)) : undefined,
};
}
function rowToReaction(r) {
return {
id: String(r.id),
messageId: String(r.messageId),
oderId: String(r.oderId ?? ''),
userId: String(r.userId ?? ''),
emoji: String(r.emoji),
timestamp: Number(r.timestamp),
};
}
function rowToAttachment(r) {
return {
id: String(r.id),
messageId: String(r.messageId),
filename: String(r.filename),
size: Number(r.size),
mime: String(r.mime),
isImage: !!r.isImage,
uploaderPeerId: r.uploaderPeerId ? String(r.uploaderPeerId) : undefined,
filePath: r.filePath ? String(r.filePath) : undefined,
savedPath: r.savedPath ? String(r.savedPath) : undefined,
};
}
function rowToBan(r) {
return {
oderId: String(r.oderId),
userId: String(r.userId ?? ''),
roomId: String(r.roomId),
bannedBy: String(r.bannedBy),
displayName: r.displayName ? String(r.displayName) : undefined,
reason: r.reason ? String(r.reason) : undefined,
expiresAt: r.expiresAt != null ? Number(r.expiresAt) : undefined,
timestamp: Number(r.timestamp),
};
}
/* ------------------------------------------------------------------ */
/* IPC handler registration */
/* ------------------------------------------------------------------ */
function registerDatabaseIpc() {
ipcMain.handle('db:initialize', async () => {
await initDatabase();
return true;
});
ipcMain.handle('db:saveMessage', (_e, message) => {
db.run(
`INSERT OR REPLACE INTO messages
(id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
message.id,
message.roomId,
message.channelId ?? null,
message.senderId,
message.senderName,
message.content,
message.timestamp,
message.editedAt ?? null,
JSON.stringify(message.reactions ?? []),
message.isDeleted ? 1 : 0,
message.replyToId ?? null,
],
);
persist();
});
ipcMain.handle('db:getMessages', (_e, roomId, limit = 100, offset = 0) => {
const rows = queryAll(
'SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?',
[roomId, limit, offset],
);
return rows.map(rowToMessage);
});
ipcMain.handle('db:deleteMessage', (_e, messageId) => {
db.run('DELETE FROM messages WHERE id = ?', [messageId]);
persist();
});
ipcMain.handle('db:updateMessage', (_e, messageId, updates) => {
const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
if (!row) return;
const msg = { ...rowToMessage(row), ...updates };
db.run(
`INSERT OR REPLACE INTO messages
(id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
msg.id, msg.roomId, msg.channelId ?? null, msg.senderId, msg.senderName,
msg.content, msg.timestamp, msg.editedAt ?? null,
JSON.stringify(msg.reactions ?? []), msg.isDeleted ? 1 : 0, msg.replyToId ?? null,
],
);
persist();
});
ipcMain.handle('db:getMessageById', (_e, messageId) => {
const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
return row ? rowToMessage(row) : null;
});
ipcMain.handle('db:clearRoomMessages', (_e, roomId) => {
db.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
persist();
});
ipcMain.handle('db:saveReaction', (_e, reaction) => {
const check = db.exec(
'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
[reaction.messageId, reaction.userId, reaction.emoji],
);
if (check.length > 0 && check[0].values.length > 0) return;
db.run(
`INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp)
VALUES (?, ?, ?, ?, ?, ?)`,
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp],
);
persist();
});
ipcMain.handle('db:removeReaction', (_e, messageId, userId, emoji) => {
db.run('DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?', [messageId, userId, emoji]);
persist();
});
ipcMain.handle('db:getReactionsForMessage', (_e, messageId) => {
const rows = queryAll('SELECT * FROM reactions WHERE messageId = ?', [messageId]);
return rows.map(rowToReaction);
});
ipcMain.handle('db:saveUser', (_e, user) => {
db.run(
`INSERT OR REPLACE INTO users
(id, oderId, username, displayName, avatarUrl, status, role, joinedAt,
peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
user.id,
user.oderId ?? null,
user.username ?? null,
user.displayName ?? null,
user.avatarUrl ?? null,
user.status ?? null,
user.role ?? null,
user.joinedAt ?? null,
user.peerId ?? null,
user.isOnline ? 1 : 0,
user.isAdmin ? 1 : 0,
user.isRoomOwner ? 1 : 0,
user.voiceState ? JSON.stringify(user.voiceState) : null,
user.screenShareState ? JSON.stringify(user.screenShareState) : null,
],
);
persist();
});
ipcMain.handle('db:getUser', (_e, userId) => {
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return row ? rowToUser(row) : null;
});
ipcMain.handle('db:getCurrentUser', () => {
const rows = db.exec("SELECT value FROM meta WHERE key = 'currentUserId'");
if (rows.length === 0 || rows[0].values.length === 0) return null;
const userId = String(rows[0].values[0][0]);
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return row ? rowToUser(row) : null;
});
ipcMain.handle('db:setCurrentUserId', (_e, userId) => {
db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)", [userId]);
persist();
});
ipcMain.handle('db:getUsersByRoom', (_e, _roomId) => {
const rows = queryAll('SELECT * FROM users');
return rows.map(rowToUser);
});
ipcMain.handle('db:updateUser', (_e, userId, updates) => {
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
if (!row) return;
const user = { ...rowToUser(row), ...updates };
db.run(
`INSERT OR REPLACE INTO users
(id, oderId, username, displayName, avatarUrl, status, role, joinedAt,
peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
user.id, user.oderId ?? null, user.username ?? null, user.displayName ?? null,
user.avatarUrl ?? null, user.status ?? null, user.role ?? null, user.joinedAt ?? null,
user.peerId ?? null, user.isOnline ? 1 : 0, user.isAdmin ? 1 : 0, user.isRoomOwner ? 1 : 0,
user.voiceState ? JSON.stringify(user.voiceState) : null,
user.screenShareState ? JSON.stringify(user.screenShareState) : null,
],
);
persist();
});
ipcMain.handle('db:saveRoom', (_e, room) => {
db.run(
`INSERT OR REPLACE INTO rooms
(id, name, description, topic, hostId, password, isPrivate, createdAt,
userCount, maxUsers, icon, iconUpdatedAt, permissions, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id, room.name, room.description ?? null, room.topic ?? null,
room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt,
room.userCount, room.maxUsers ?? null, room.icon ?? null,
room.iconUpdatedAt ?? null,
room.permissions ? JSON.stringify(room.permissions) : null,
room.channels ? JSON.stringify(room.channels) : null,
],
);
persist();
});
ipcMain.handle('db:getRoom', (_e, roomId) => {
const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
return row ? rowToRoom(row) : null;
});
ipcMain.handle('db:getAllRooms', () => {
const rows = queryAll('SELECT * FROM rooms');
return rows.map(rowToRoom);
});
ipcMain.handle('db:deleteRoom', (_e, roomId) => {
db.run('DELETE FROM rooms WHERE id = ?', [roomId]);
db.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
persist();
});
ipcMain.handle('db:updateRoom', (_e, roomId, updates) => {
const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
if (!row) return;
const room = { ...rowToRoom(row), ...updates };
db.run(
`INSERT OR REPLACE INTO rooms
(id, name, description, topic, hostId, password, isPrivate, createdAt,
userCount, maxUsers, icon, iconUpdatedAt, permissions, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id, room.name, room.description ?? null, room.topic ?? null,
room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt,
room.userCount, room.maxUsers ?? null, room.icon ?? null,
room.iconUpdatedAt ?? null,
room.permissions ? JSON.stringify(room.permissions) : null,
room.channels ? JSON.stringify(room.channels) : null,
],
);
persist();
});
ipcMain.handle('db:saveBan', (_e, ban) => {
db.run(
`INSERT OR REPLACE INTO bans
(oderId, userId, roomId, bannedBy, displayName, reason, expiresAt, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
ban.oderId, ban.userId ?? null, ban.roomId, ban.bannedBy,
ban.displayName ?? null, ban.reason ?? null, ban.expiresAt ?? null, ban.timestamp,
],
);
persist();
});
ipcMain.handle('db:removeBan', (_e, oderId) => {
db.run('DELETE FROM bans WHERE oderId = ?', [oderId]);
persist();
});
ipcMain.handle('db:getBansForRoom', (_e, roomId) => {
const now = Date.now();
const rows = queryAll(
'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)',
[roomId, now],
);
return rows.map(rowToBan);
});
ipcMain.handle('db:isUserBanned', (_e, userId, roomId) => {
const now = Date.now();
const rows = queryAll(
'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)',
[roomId, now],
);
return rows.some((r) => String(r.oderId) === userId);
});
ipcMain.handle('db:saveAttachment', (_e, attachment) => {
db.run(
`INSERT OR REPLACE INTO attachments
(id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
attachment.id, attachment.messageId, attachment.filename,
attachment.size, attachment.mime, attachment.isImage ? 1 : 0,
attachment.uploaderPeerId ?? null, attachment.filePath ?? null,
attachment.savedPath ?? null,
],
);
persist();
});
ipcMain.handle('db:getAttachmentsForMessage', (_e, messageId) => {
const rows = queryAll('SELECT * FROM attachments WHERE messageId = ?', [messageId]);
return rows.map(rowToAttachment);
});
ipcMain.handle('db:getAllAttachments', () => {
const rows = queryAll('SELECT * FROM attachments');
return rows.map(rowToAttachment);
});
ipcMain.handle('db:deleteAttachmentsForMessage', (_e, messageId) => {
db.run('DELETE FROM attachments WHERE messageId = ?', [messageId]);
persist();
});
ipcMain.handle('db:clearAllData', () => {
db.run('DELETE FROM messages');
db.run('DELETE FROM users');
db.run('DELETE FROM rooms');
db.run('DELETE FROM reactions');
db.run('DELETE FROM bans');
db.run('DELETE FROM attachments');
db.run('DELETE FROM meta');
persist();
});
}
module.exports = { registerDatabaseIpc };