fix: improve plugins functionality with server management

This commit is contained in:
2026-04-29 20:33:54 +02:00
parent b8f6d58d99
commit fa2cca6fa4
82 changed files with 1708 additions and 303 deletions

View File

@@ -28,6 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
## Notes
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
- Plugin client data is stored in the local Electron SQLite database in the dedicated user-scoped `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -11,7 +11,6 @@ export interface IssuedToken {
}
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const tokens = new Map<string, IssuedToken>();
export function issueToken(params: {

View File

@@ -59,6 +59,17 @@ export function getDocsHtml(specUrl: string): string {
disabled: true
}
};
const contentSecurityPolicy = [
"default-src 'none'",
"script-src 'self' 'nonce-metoyou-local-api-docs'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'"
].join('; ');
return `<!doctype html>
<html lang="en">
@@ -67,7 +78,7 @@ export function getDocsHtml(specUrl: string): string {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self' 'nonce-metoyou-local-api-docs'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
content="${contentSecurityPolicy}"
/>
<title>MetoYou Local API</title>
<style>

View File

@@ -72,7 +72,11 @@ export async function resolveDocusaurusRoute(pathname: string): Promise<{ filePa
const root = await getDocusaurusBuildRoot();
if (!root) {
throw new HttpError(503, 'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.', 'DOCUSAURUS_BUILD_MISSING');
throw new HttpError(
503,
'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.',
'DOCUSAURUS_BUILD_MISSING'
);
}
let filePath = resolveAssetPath(root, pathname);

View File

@@ -37,6 +37,7 @@ export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
}
const chunks: Buffer[] = [];
let received = 0;
for await (const chunk of req) {

View File

@@ -196,9 +196,9 @@ export async function startLocalApiServer(settings: LocalApiSettings): Promise<S
currentError = null;
currentBindHost = pickBindHost(settings);
currentBindPort = settings.port;
const requestSettings = activeSettings;
const httpServer = createServer((req, res) => {
void handleRequest(req, res, activeSettings!).catch((error) => {
void handleRequest(req, res, requestSettings).catch((error) => {
console.error('[LocalApi] Unhandled request error:', error);
try {

View File

@@ -36,7 +36,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
},
LoginRequest: {
type: 'object',
required: ['username', 'password', 'serverUrl'],
required: [
'username',
'password',
'serverUrl'
],
properties: {
username: { type: 'string' },
password: { type: 'string' },
@@ -49,7 +53,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
},
LoginResponse: {
type: 'object',
required: ['token', 'expiresAt', 'user'],
required: [
'token',
'expiresAt',
'user'
],
properties: {
token: { type: 'string' },
expiresAt: { type: 'integer', format: 'int64' },
@@ -58,7 +66,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
},
AuthUser: {
type: 'object',
required: ['id', 'username', 'displayName'],
required: [
'id',
'username',
'displayName'
],
properties: {
id: { type: 'string' },
username: { type: 'string' },

View File

@@ -1,10 +1,19 @@
import { app, net } from 'electron';
import { DataSource } from 'typeorm';
import { buildQueryHandlers } from '../cqrs/queries';
import { QueryType, QueryTypeKey, Query } from '../cqrs/types';
import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store';
import {
QueryType,
QueryTypeKey,
Query
} from '../cqrs/types';
import {
issueToken,
consumeToken,
revokeToken,
IssuedToken
} from './auth-store';
import { buildOpenApiDocument } from './openapi';
import { HttpError, RequestContext, readJsonBody } from './http-helpers';
import { HttpError, RequestContext } from './http-helpers';
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
import { resolveDocusaurusRoute } from './docusaurus-static';
import { LocalApiSettings } from '../desktop-settings';
@@ -48,12 +57,14 @@ function compilePattern(template: string): { pattern: RegExp; paramKeys: string[
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
if (match === '*' || match === '+' || match === '?')
return `\\${match}`;
return `\\${match}`;
});
const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => {
paramKeys.push(key);
return '([^/]+)';
});
void escaped;
return { pattern: new RegExp(`^${source}$`), paramKeys };
@@ -273,7 +284,6 @@ const ROUTES: RouteDefinition[] = [
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessages,
payload: { roomId: decodeURIComponent(roomId), limit, offset }

View File

@@ -1,9 +1,15 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { ClearRoomMessagesCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const currentUserId = await getCurrentUserScope(dataSource);
await repo.delete({ roomId: command.payload.roomId });
if (!currentUserId) {
return;
}
await repo.delete({ roomId: command.payload.roomId, ownerUserId: currentUserId });
}

View File

@@ -1,9 +1,15 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { DeleteMessageCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const currentUserId = await getCurrentUserScope(dataSource);
await repo.delete({ id: command.payload.messageId });
if (!currentUserId) {
return;
}
await repo.delete({ id: command.payload.messageId, ownerUserId: currentUserId });
}

View File

@@ -1,12 +1,19 @@
import { DataSource } from 'typeorm';
import { getCurrentUserScope } from '../../current-user-scope';
import { PluginDataEntity } from '../../../entities';
import { DeletePluginDataCommand } from '../../types';
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
const ownerUserId = await getCurrentUserScope(dataSource);
if (!ownerUserId) {
return;
}
await dataSource.getRepository(PluginDataEntity).delete({
key: payload.key,
ownerUserId,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''

View File

@@ -3,23 +3,39 @@ import {
RoomChannelPermissionEntity,
RoomChannelEntity,
RoomEntity,
RoomOwnerEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
MessageEntity
} from '../../../entities';
import { DeleteRoomCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
if (!currentUserId) {
return;
}
await manager.getRepository(RoomOwnerEntity).delete({ roomId, userId: currentUserId });
await manager.getRepository(MessageEntity).delete({ roomId, ownerUserId: currentUserId });
const remainingOwners = await manager.getRepository(RoomOwnerEntity).count({ where: { roomId } });
if (remainingOwners > 0) {
return;
}
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomRoleEntity).delete({ roomId });
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(MessageEntity).delete({ roomId });
});
}

View File

@@ -2,15 +2,18 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { SaveMessageCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const { message } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
const repo = manager.getRepository(MessageEntity);
const entity = repo.create({
id: message.id,
roomId: message.roomId,
ownerUserId: currentUserId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,

View File

@@ -1,12 +1,19 @@
import { DataSource } from 'typeorm';
import { getCurrentUserScope } from '../../current-user-scope';
import { PluginDataEntity } from '../../../entities';
import { SavePluginDataCommand } from '../../types';
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
const ownerUserId = await getCurrentUserScope(dataSource);
if (!ownerUserId) {
return;
}
await dataSource.getRepository(PluginDataEntity).save({
key: payload.key,
ownerUserId,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? '',

View File

@@ -1,7 +1,8 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { RoomEntity, RoomOwnerEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
@@ -21,6 +22,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
const { room } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
const repo = manager.getRepository(RoomEntity);
const entity = repo.create({
id: room.id,
@@ -43,6 +45,15 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
});
await repo.save(entity);
if (currentUserId) {
await manager.getRepository(RoomOwnerEntity).save({
roomId: room.id,
userId: currentUserId,
savedAt: Date.now()
});
}
await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [],
members: room.members ?? [],

View File

@@ -2,13 +2,20 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { UpdateMessageCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const { messageId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
if (!currentUserId) {
return;
}
const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId } });
const existing = await repo.findOne({ where: { id: messageId, ownerUserId: currentUserId } });
if (!existing)
return;

View File

@@ -7,6 +7,7 @@ import {
boolToInt,
TransformMap
} from './utils/applyUpdates';
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
@@ -32,6 +33,12 @@ export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: D
const { roomId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
if (!await userOwnsRoom(manager, roomId, currentUserId)) {
return;
}
const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } });

View File

@@ -0,0 +1,24 @@
import { DataSource, EntityManager } from 'typeorm';
import { MetaEntity, RoomOwnerEntity } from '../entities';
export async function getCurrentUserScope(dataSourceOrManager: DataSource | EntityManager): Promise<string | null> {
const repo = dataSourceOrManager.getRepository(MetaEntity);
const meta = await repo.findOne({ where: { key: 'currentUserId' } });
return meta?.value?.trim() || null;
}
export async function userOwnsRoom(
dataSourceOrManager: DataSource | EntityManager,
roomId: string,
userId: string | null
): Promise<boolean> {
if (!userId) {
return false;
}
const repo = dataSourceOrManager.getRepository(RoomOwnerEntity);
const owner = await repo.findOne({ where: { roomId, userId } });
return !!owner;
}

View File

@@ -1,11 +1,28 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { RoomEntity, RoomOwnerEntity } from '../../../entities';
import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleGetAllRooms(dataSource: DataSource) {
const currentUserId = await getCurrentUserScope(dataSource);
if (!currentUserId) {
return [];
}
const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find();
const ownershipRows = await dataSource.getRepository(RoomOwnerEntity).find({ where: { userId: currentUserId } });
const roomIds = ownershipRows.map((owner) => owner.roomId);
if (roomIds.length === 0) {
return [];
}
const rows = await repo
.createQueryBuilder('room')
.where('room.id IN (:...roomIds)', { roomIds })
.getMany();
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));

View File

@@ -6,4 +6,4 @@ export async function handleGetCurrentUserId(dataSource: DataSource): Promise<st
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
return metaRow?.value?.trim() || null;
}
}

View File

@@ -3,10 +3,17 @@ import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } });
const currentUserId = await getCurrentUserScope(dataSource);
if (!currentUserId) {
return null;
}
const row = await repo.findOne({ where: { id: query.payload.messageId, ownerUserId: currentUserId } });
if (!row) {
return null;

View File

@@ -3,12 +3,19 @@ import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, limit = 100, offset = 0 } = query.payload;
const currentUserId = await getCurrentUserScope(dataSource);
if (!currentUserId) {
return [];
}
const rows = await repo.find({
where: { roomId },
where: { roomId, ownerUserId: currentUserId },
order: { timestamp: 'ASC' },
take: limit,
skip: offset

View File

@@ -3,13 +3,21 @@ import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, sinceTimestamp } = query.payload;
const currentUserId = await getCurrentUserScope(dataSource);
if (!currentUserId) {
return [];
}
const rows = await repo.find({
where: {
roomId,
ownerUserId: currentUserId,
timestamp: MoreThan(sinceTimestamp)
},
order: { timestamp: 'ASC' }

View File

@@ -1,12 +1,20 @@
import { DataSource } from 'typeorm';
import { getCurrentUserScope } from '../../current-user-scope';
import { PluginDataEntity } from '../../../entities';
import { GetPluginDataQuery } from '../../types';
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
const { payload } = query;
const ownerUserId = await getCurrentUserScope(dataSource);
if (!ownerUserId) {
return null;
}
const record = await dataSource.getRepository(PluginDataEntity).findOne({
where: {
key: payload.key,
ownerUserId,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''

View File

@@ -3,8 +3,15 @@ import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const currentUserId = await getCurrentUserScope(dataSource);
if (!await userOwnsRoom(dataSource, query.payload.roomId, currentUserId)) {
return null;
}
const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } });

View File

@@ -22,12 +22,12 @@ const ZIP_UTF8_FLAG = 0x0800;
const ZIP_STORE_METHOD = 0;
const ZIP_VERSION = 20;
const MAX_UINT32 = 0xffffffff;
const crcTable = buildCrcTable();
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
const localParts: Buffer[] = [];
const centralEntries: CentralDirectoryEntry[] = [];
let offset = 0;
for (const entry of entries) {
@@ -93,7 +93,6 @@ export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
return Buffer.concat([header, entry.name]);
});
const centralDirectorySize = offset - centralDirectoryOffset;
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
@@ -111,7 +110,11 @@ export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
end.writeUInt32LE(centralDirectoryOffset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, ...centralParts, end]);
return Buffer.concat([
...localParts,
...centralParts,
end
]);
}
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
@@ -124,6 +127,7 @@ export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
const entryCount = data.readUInt16LE(endOffset + 10);
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
const entries: ZipArchiveEntry[] = [];
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {

View File

@@ -43,12 +43,11 @@ export async function openCurrentDataFolder(): Promise<boolean> {
export async function exportUserData(): Promise<ExportUserDataResult> {
const dataPath = app.getPath('userData');
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
const defaultFileName = `metoyou-data-${new Date().toISOString()
.slice(0, 10)}.dat`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), defaultFileName),
filters: [
{ extensions: ['dat'], name: 'MetoYou data archive' }
],
filters: [{ extensions: ['dat'], name: 'MetoYou data archive' }],
title: 'Export MetoYou data'
});
@@ -88,9 +87,7 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
export async function importUserData(): Promise<ImportUserDataResult> {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
],
filters: [{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }],
properties: ['openFile'],
title: 'Import MetoYou data'
});
@@ -184,7 +181,8 @@ async function collectDataFiles(directoryPath: string): Promise<string[]> {
async function moveCurrentDataAside(): Promise<string | undefined> {
const dataPath = app.getPath('userData');
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString()
.replace(/[:.]/g, '-')}`);
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
await fsp.mkdir(backupPath, { recursive: true });
@@ -204,6 +202,7 @@ async function moveCurrentDataAside(): Promise<string | undefined> {
await copyPath(sourcePath, targetPath);
await fsp.rm(sourcePath, { force: true, recursive: true });
});
movedAny = true;
}

View File

@@ -8,6 +8,7 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomOwnerEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
@@ -27,8 +28,18 @@ let dbBackupPath = '';
// SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0';
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
const SAVE_RETRY_DELAYS_MS = [
25,
75,
150,
300,
600
];
const RETRYABLE_SAVE_ERROR_CODES = new Set([
'EPERM',
'EACCES',
'EBUSY'
]);
let saveQueue: Promise<void> = Promise.resolve();
@@ -164,6 +175,7 @@ export async function initializeDatabase(): Promise<void> {
MessageEntity,
UserEntity,
RoomEntity,
RoomOwnerEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,

View File

@@ -37,7 +37,6 @@ const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = {
docusaurusEnabled: false,
allowedSignalingServers: []
};
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto',
autoStart: true,

View File

@@ -12,6 +12,9 @@ export class MessageEntity {
@Column('text')
roomId!: string;
@Column('text', { nullable: true })
ownerUserId!: string | null;
@Column('text', { nullable: true })
channelId!: string | null;

View File

@@ -6,6 +6,9 @@ import {
@Entity('plugin_data')
export class PluginDataEntity {
@PrimaryColumn('text')
ownerUserId!: string;
@PrimaryColumn('text')
pluginId!: string;

View File

@@ -0,0 +1,19 @@
import {
Column,
Entity,
Index,
PrimaryColumn
} from 'typeorm';
@Entity('room_owners')
export class RoomOwnerEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
@Index()
userId!: string;
@Column('integer')
savedAt!: number;
}

View File

@@ -1,6 +1,7 @@
export { MessageEntity } from './MessageEntity';
export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { RoomOwnerEntity } from './RoomOwnerEntity';
export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity';
export { RoomRoleEntity } from './RoomRoleEntity';

View File

@@ -18,10 +18,7 @@ import {
updateDesktopSettings,
type DesktopSettings
} from '../desktop-settings';
import {
applyLocalApiSettings,
getLocalApiSnapshot
} from '../api';
import { applyLocalApiSettings, getLocalApiSnapshot } from '../api';
import {
activateLinuxScreenShareAudioRouting,
deactivateLinuxScreenShareAudioRouting,
@@ -490,6 +487,7 @@ export function setupSystemHandlers(): void {
docusaurusEnabled: true
}
});
await applyLocalApiSettings();
snapshot = getLocalApiSnapshot();
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UserScopedRoomsAndMessages1000000000009 implements MigrationInterface {
name = 'UserScopedRoomsAndMessages1000000000009';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_owners" (
"roomId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"savedAt" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "userId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_owners_userId" ON "room_owners" ("userId")`);
const columns = await queryRunner.query(`PRAGMA table_info("messages")`) as Array<{ name?: string }>;
const hasOwnerUserId = columns.some((column) => column.name === 'ownerUserId');
if (!hasOwnerUserId) {
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "ownerUserId" TEXT`);
}
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_owner_room" ON "messages" ("ownerUserId", "roomId")`);
const metaRows = await queryRunner.query(`SELECT "value" FROM "meta" WHERE "key" = 'currentUserId' LIMIT 1`) as Array<{ value?: string | null }>;
const currentUserId = metaRows[0]?.value?.trim();
if (!currentUserId) {
return;
}
const now = Date.now();
await queryRunner.query(
`INSERT OR IGNORE INTO "room_owners" ("roomId", "userId", "savedAt") SELECT "id", ?, ? FROM "rooms"`,
[currentUserId, now]
);
await queryRunner.query(
`UPDATE "messages" SET "ownerUserId" = ? WHERE "ownerUserId" IS NULL`,
[currentUserId]
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "idx_messages_owner_room"`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_room_owners_userId"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_owners"`);
}
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UserScopedPluginData1000000000010 implements MigrationInterface {
name = 'UserScopedPluginData1000000000010';
public async up(queryRunner: QueryRunner): Promise<void> {
const columns = await queryRunner.query(`PRAGMA table_info("plugin_data")`) as Array<{ name?: string }>;
const hasOwnerUserId = columns.some((column) => column.name === 'ownerUserId');
if (hasOwnerUserId) {
return;
}
const metaRows = await queryRunner.query(`SELECT "value" FROM "meta" WHERE "key" = 'currentUserId' LIMIT 1`) as Array<{ value?: string | null }>;
const currentUserId = metaRows[0]?.value?.trim() ?? '';
await queryRunner.query(`
CREATE TABLE "temporary_plugin_data" (
"ownerUserId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("ownerUserId", "pluginId", "scope", "serverId", "key")
)
`);
await queryRunner.query(
`INSERT INTO "temporary_plugin_data" ("ownerUserId", "pluginId", "scope", "serverId", "key", "valueJson", "updatedAt")
SELECT ?, "pluginId", "scope", "serverId", "key", "valueJson", "updatedAt" FROM "plugin_data"`,
[currentUserId]
);
await queryRunner.query(`DROP TABLE "plugin_data"`);
await queryRunner.query(`ALTER TABLE "temporary_plugin_data" RENAME TO "plugin_data"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_owner_plugin_scope" ON "plugin_data" ("ownerUserId", "pluginId", "scope")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_plugin_data" (
"pluginId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("pluginId", "scope", "serverId", "key")
)`);
await queryRunner.query(`INSERT OR REPLACE INTO "temporary_plugin_data" ("pluginId", "scope", "serverId", "key", "valueJson", "updatedAt")
SELECT "pluginId", "scope", "serverId", "key", "valueJson", "updatedAt" FROM "plugin_data"`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_owner_plugin_scope"`);
await queryRunner.query(`DROP TABLE "plugin_data"`);
await queryRunner.query(`ALTER TABLE "temporary_plugin_data" RENAME TO "plugin_data"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_plugin_scope" ON "plugin_data" ("pluginId", "scope")`);
}
}