fix: improve plugins functionality with server management
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
24
electron/cqrs/current-user-scope.ts
Normal file
24
electron/cqrs/current-user-scope.ts
Normal 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;
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,6 @@ const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = {
|
||||
docusaurusEnabled: false,
|
||||
allowedSignalingServers: []
|
||||
};
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
autoStart: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
|
||||
@Entity('plugin_data')
|
||||
export class PluginDataEntity {
|
||||
@PrimaryColumn('text')
|
||||
ownerUserId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
|
||||
19
electron/entities/RoomOwnerEntity.ts
Normal file
19
electron/entities/RoomOwnerEntity.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
56
electron/migrations/1000000000010-UserScopedPluginData.ts
Normal file
56
electron/migrations/1000000000010-UserScopedPluginData.ts
Normal 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")`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user