diff --git a/backend/package-lock.json b/backend/package-lock.json index af4f6e60..9bf65d79 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -46,6 +46,7 @@ "safe-regex": "^2.0.2", "seedrandom": "^3.0.1", "strip-combining-marks": "^1.0.0", + "threads": "^1.7.0", "tlds": "^1.221.1", "tmp": "0.0.33", "tsconfig-paths": "^3.9.0", @@ -2234,6 +2235,15 @@ "node": ">=0.8.0" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -2923,6 +2933,17 @@ "node": ">=8" } }, + "node_modules/is-observable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz", + "integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -3544,6 +3565,11 @@ "node": ">=0.10.0" } }, + "node_modules/observable-fns": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", + "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==" + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -4983,6 +5009,44 @@ "node": ">=0.8" } }, + "node_modules/threads": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/threads/-/threads-1.7.0.tgz", + "integrity": "sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==", + "dependencies": { + "callsites": "^3.1.0", + "debug": "^4.2.0", + "is-observable": "^2.1.0", + "observable-fns": "^0.6.1" + }, + "funding": { + "url": "https://github.com/andywer/threads.js?sponsor=1" + }, + "optionalDependencies": { + "tiny-worker": ">= 2" + } + }, + "node_modules/threads/node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/threads/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -4998,6 +5062,15 @@ "node": ">=4" } }, + "node_modules/tiny-worker": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz", + "integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==", + "optional": true, + "dependencies": { + "esm": "^3.2.25" + } + }, "node_modules/tlds": { "version": "1.221.1", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.221.1.tgz", @@ -7677,6 +7750,12 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, + "esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "optional": true + }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -8195,6 +8274,11 @@ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" }, + "is-observable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz", + "integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==" + }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -8691,6 +8775,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "observable-fns": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", + "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -9807,6 +9896,33 @@ "thenify": ">= 3.1.0 < 4" } }, + "threads": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/threads/-/threads-1.7.0.tgz", + "integrity": "sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==", + "requires": { + "callsites": "^3.1.0", + "debug": "^4.2.0", + "is-observable": "^2.1.0", + "observable-fns": "^0.6.1", + "tiny-worker": ">= 2" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -9819,6 +9935,15 @@ "integrity": "sha1-mcW/VZWJZq9tBtg73zgA3IL67F0=", "dev": true }, + "tiny-worker": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz", + "integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==", + "optional": true, + "requires": { + "esm": "^3.2.25" + } + }, "tlds": { "version": "1.221.1", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.221.1.tgz", diff --git a/backend/package.json b/backend/package.json index 0bbe2565..84c5973b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,6 +61,7 @@ "safe-regex": "^2.0.2", "seedrandom": "^3.0.1", "strip-combining-marks": "^1.0.0", + "threads": "^1.7.0", "tlds": "^1.221.1", "tmp": "0.0.33", "tsconfig-paths": "^3.9.0", diff --git a/backend/src/data/BaseGuildRepository.ts b/backend/src/data/BaseGuildRepository.ts index 47cbceaf..7d756fce 100644 --- a/backend/src/data/BaseGuildRepository.ts +++ b/backend/src/data/BaseGuildRepository.ts @@ -1,6 +1,6 @@ import { BaseRepository } from "./BaseRepository"; -export class BaseGuildRepository extends BaseRepository { +export class BaseGuildRepository extends BaseRepository { private static guildInstances: Map; protected guildId: string; diff --git a/backend/src/data/BaseRepository.ts b/backend/src/data/BaseRepository.ts index c78d050d..1364b23a 100644 --- a/backend/src/data/BaseRepository.ts +++ b/backend/src/data/BaseRepository.ts @@ -1,4 +1,6 @@ -export class BaseRepository { +import { asyncMap } from "../utils/async"; + +export class BaseRepository { private nextRelations: string[]; constructor() { @@ -27,4 +29,26 @@ export class BaseRepository { this.nextRelations = []; return relations; } + + protected async _processEntityFromDB(entity) { + // No-op, override in repository + return entity; + } + + protected async _processEntityToDB(entity) { + // No-op, override in repository + return entity; + } + + protected async processEntityFromDB(entity: T): Promise { + return this._processEntityFromDB(entity); + } + + protected async processMultipleEntitiesFromDB(entities: TArr): Promise { + return asyncMap(entities, (entity) => this.processEntityFromDB(entity)) as Promise; + } + + protected async processEntityToDB>(entity: T): Promise { + return this._processEntityToDB(entity); + } } diff --git a/backend/src/data/GuildArchives.ts b/backend/src/data/GuildArchives.ts index 6bff7eaa..3b5742b8 100644 --- a/backend/src/data/GuildArchives.ts +++ b/backend/src/data/GuildArchives.ts @@ -6,12 +6,13 @@ import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter import { trimLines } from "../utils"; import { BaseGuildRepository } from "./BaseGuildRepository"; import { ArchiveEntry } from "./entities/ArchiveEntry"; -import { SavedMessage } from "./entities/SavedMessage"; import { channelToTemplateSafeChannel, guildToTemplateSafeGuild, userToTemplateSafeUser, } from "../utils/templateSafeObjects"; +import { decryptJson, encryptJson } from "../utils/cryptHelpers"; +import { SavedMessage } from "./entities/SavedMessage"; const DEFAULT_EXPIRY_DAYS = 30; @@ -21,7 +22,7 @@ const MESSAGE_ARCHIVE_HEADER_FORMAT = trimLines(` const MESSAGE_ARCHIVE_MESSAGE_FORMAT = "[#{channel.name}] [{user.id}] [{timestamp}] {user.username}#{user.discriminator}: {content}{attachments}{stickers}"; -export class GuildArchives extends BaseGuildRepository { +export class GuildArchives extends BaseGuildRepository { protected archives: Repository; constructor(guildId) { @@ -29,11 +30,28 @@ export class GuildArchives extends BaseGuildRepository { this.archives = getRepository(ArchiveEntry); } + protected async _processEntityFromDB(entity: ArchiveEntry | undefined) { + if (entity == null) { + return entity; + } + + entity.body = await decryptJson(entity.body as unknown as string); + return entity; + } + + protected async _processEntityToDB(entity: Partial) { + if (entity.body) { + entity.body = (await encryptJson(entity.body)) as any; + } + return entity; + } + async find(id: string): Promise { - return this.archives.findOne({ + const result = await this.archives.findOne({ where: { id }, relations: this.getRelations(), }); + return this.processEntityFromDB(result); } async makePermanent(id: string): Promise { @@ -46,23 +64,24 @@ export class GuildArchives extends BaseGuildRepository { } /** - * @returns ID of the created entry + * @return - ID of the created archive */ async create(body: string, expiresAt?: moment.Moment): Promise { if (!expiresAt) { expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days"); } - const result = await this.archives.insert({ + const data = await this.processEntityToDB({ guild_id: this.guildId, body, expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss"), }); + const result = await this.archives.insert(data); return result.identifiers[0].id; } - protected async renderLinesFromSavedMessages(savedMessages: SavedMessage[], guild: Guild) { + protected async renderLinesFromSavedMessages(savedMessages: SavedMessage[], guild: Guild): Promise { const msgLines: string[] = []; for (const msg of savedMessages) { const channel = guild.channels.cache.get(msg.channel_id as Snowflake); @@ -90,7 +109,14 @@ export class GuildArchives extends BaseGuildRepository { return msgLines; } - async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt?: moment.Moment) { + /** + * @return - ID of the created archive + */ + async createFromSavedMessages( + savedMessages: SavedMessage[], + guild: Guild, + expiresAt?: moment.Moment, + ): Promise { if (expiresAt == null) { expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days"); } @@ -111,12 +137,13 @@ export class GuildArchives extends BaseGuildRepository { const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); const messagesStr = msgLines.join("\n"); - const archive = await this.find(archiveId); + let archive = await this.find(archiveId); if (archive == null) { throw new Error("Archive not found"); } archive.body += "\n" + messagesStr; + archive = await this.processEntityToDB(archive); await this.archives.update({ id: archiveId }, { body: archive.body }); } diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 9c0fcb25..1f021f1f 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -7,8 +7,11 @@ import { BaseGuildRepository } from "./BaseGuildRepository"; import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage"; import { buildEntity } from "./buildEntity"; import { noop } from "../utils"; +import { decrypt } from "../utils/crypt"; +import { decryptJson, encryptJson } from "../utils/cryptHelpers"; +import { asyncMap } from "../utils/async"; -export class GuildSavedMessages extends BaseGuildRepository { +export class GuildSavedMessages extends BaseGuildRepository { private messages: Repository; protected toBePermanent: Set; @@ -22,7 +25,7 @@ export class GuildSavedMessages extends BaseGuildRepository { this.toBePermanent = new Set(); } - public msgToSavedMessageData(msg: Message): ISavedMessageData { + protected msgToSavedMessageData(msg: Message): ISavedMessageData { const data: ISavedMessageData = { author: { username: msg.author.username, @@ -120,52 +123,38 @@ export class GuildSavedMessages extends BaseGuildRepository { return data; } - find(id) { - return this.messages - .createQueryBuilder() - .where("guild_id = :guild_id", { guild_id: this.guildId }) - .andWhere("id = :id", { id }) - .andWhere("deleted_at IS NULL") - .getOne(); + protected async _processEntityFromDB(entity: SavedMessage | undefined) { + if (entity == null) { + return entity; + } + + entity.data = await decryptJson(entity.data as unknown as string); + return entity; } - getLatestBotMessagesByChannel(channelId, limit) { - return this.messages - .createQueryBuilder() - .where("guild_id = :guild_id", { guild_id: this.guildId }) - .andWhere("channel_id = :channel_id", { channel_id: channelId }) - .andWhere("is_bot = 1") - .andWhere("deleted_at IS NULL") - .orderBy("id", "DESC") - .limit(limit) - .getMany(); + protected async _processEntityToDB(entity: Partial) { + if (entity.data) { + entity.data = (await encryptJson(entity.data)) as any; + } + return entity; } - getLatestByChannelBeforeId(channelId, beforeId, limit) { - return this.messages + async find(id: string, includeDeleted = false): Promise { + let query = this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) - .andWhere("channel_id = :channel_id", { channel_id: channelId }) - .andWhere("id < :beforeId", { beforeId }) - .andWhere("deleted_at IS NULL") - .orderBy("id", "DESC") - .limit(limit) - .getMany(); + .andWhere("id = :id", { id }); + + if (!includeDeleted) { + query = query.andWhere("deleted_at IS NULL"); + } + + const result = await query.getOne(); + + return this.processEntityFromDB(result); } - getLatestByChannelAndUser(channelId, userId, limit) { - return this.messages - .createQueryBuilder() - .where("guild_id = :guild_id", { guild_id: this.guildId }) - .andWhere("channel_id = :channel_id", { channel_id: channelId }) - .andWhere("user_id = :user_id", { user_id: userId }) - .andWhere("deleted_at IS NULL") - .orderBy("id", "DESC") - .limit(limit) - .getMany(); - } - - getUserMessagesByChannelAfterId(userId, channelId, afterId, limit?: number) { + async getUserMessagesByChannelAfterId(userId, channelId, afterId, limit?: number): Promise { let query = this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) @@ -178,15 +167,19 @@ export class GuildSavedMessages extends BaseGuildRepository { query = query.limit(limit); } - return query.getMany(); + const results = await query.getMany(); + + return this.processMultipleEntitiesFromDB(results); } - getMultiple(messageIds: string[]): Promise { - return this.messages + async getMultiple(messageIds: string[]): Promise { + const results = await this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("id IN (:messageIds)", { messageIds }) .getMany(); + + return this.processMultipleEntitiesFromDB(results); } async createFromMsg(msg: Message, overrides = {}): Promise { @@ -199,15 +192,18 @@ export class GuildSavedMessages extends BaseGuildRepository { } async createFromMessages(messages: Message[], overrides = {}): Promise { - const items = messages.map((msg) => ({ ...this.msgToInsertReadyEntity(msg), ...overrides })); + const items = await asyncMap(messages, async (msg) => ({ + ...(await this.msgToInsertReadyEntity(msg)), + ...overrides, + })); await this.insertBulk(items); } - protected msgToInsertReadyEntity(msg: Message): Partial { + protected async msgToInsertReadyEntity(msg: Message): Promise> { const savedMessageData = this.msgToSavedMessageData(msg); const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss"); - return { + return this.processEntityToDB({ id: msg.id, guild_id: (msg.channel as GuildChannel).guild.id, channel_id: msg.channel.id, @@ -215,7 +211,7 @@ export class GuildSavedMessages extends BaseGuildRepository { is_bot: msg.author.bot, data: savedMessageData, posted_at: postedAt, - }; + }); } protected async insertBulk(items: Array>): Promise { @@ -247,7 +243,7 @@ export class GuildSavedMessages extends BaseGuildRepository { .andWhere("id = :id", { id }) .execute(); - const deleted = await this.messages.findOne(id); + const deleted = await this.find(id, true); if (deleted) { this.events.emit("delete", [deleted]); @@ -271,42 +267,40 @@ export class GuildSavedMessages extends BaseGuildRepository { .andWhere("deleted_at IS NULL") .execute(); - const deleted = await this.messages + let deleted = await this.messages .createQueryBuilder() .where("id IN (:ids)", { ids }) .andWhere("deleted_at = :deletedAt", { deletedAt }) .getMany(); + deleted = await this.processMultipleEntitiesFromDB(deleted); if (deleted.length) { this.events.emit("deleteBulk", [deleted]); } } - async saveEdit(id, newData: ISavedMessageData) { - const oldMessage = await this.messages.findOne(id); + async saveEdit(id, newData: ISavedMessageData): Promise { + const oldMessage = await this.find(id); if (!oldMessage) return; const newMessage = { ...oldMessage, data: newData }; // @ts-ignore - await this.messages.update( - // FIXME? - { id }, - { - data: newData as QueryDeepPartialEntity, - }, - ); + const updateData = await this.processEntityToDB({ + data: newData, + }); + await this.messages.update({ id }, updateData); this.events.emit("update", [newMessage, oldMessage]); this.events.emit(`update:${id}`, [newMessage, oldMessage]); } - async saveEditFromMsg(msg: Message) { + async saveEditFromMsg(msg: Message): Promise { const newData = this.msgToSavedMessageData(msg); - return this.saveEdit(msg.id, newData); + await this.saveEdit(msg.id, newData); } - async setPermanent(id: string) { + async setPermanent(id: string): Promise { const savedMsg = await this.find(id); if (savedMsg) { await this.messages.update( @@ -320,7 +314,11 @@ export class GuildSavedMessages extends BaseGuildRepository { } } - async onceMessageAvailable(id: string, handler: (msg?: SavedMessage) => any, timeout: number = 60 * 1000) { + async onceMessageAvailable( + id: string, + handler: (msg?: SavedMessage) => any, + timeout: number = 60 * 1000, + ): Promise { let called = false; let onceEventListener; let timeoutFn; diff --git a/backend/src/data/encryptedJsonTransformer.ts b/backend/src/data/encryptedJsonTransformer.ts deleted file mode 100644 index 38272ebc..00000000 --- a/backend/src/data/encryptedJsonTransformer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ValueTransformer } from "typeorm"; -import { decrypt, encrypt } from "../utils/crypt"; - -interface EncryptedJsonTransformer extends ValueTransformer { - from(dbValue: any): T; - to(entityValue: T): any; -} - -export function createEncryptedJsonTransformer(): EncryptedJsonTransformer { - return { - // Database -> Entity - from(dbValue) { - const decrypted = decrypt(dbValue); - return JSON.parse(decrypted) as T; - }, - - // Entity -> Database - to(entityValue) { - return encrypt(JSON.stringify(entityValue)); - }, - }; -} diff --git a/backend/src/data/encryptedTextTransformer.ts b/backend/src/data/encryptedTextTransformer.ts deleted file mode 100644 index 7d0432ae..00000000 --- a/backend/src/data/encryptedTextTransformer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ValueTransformer } from "typeorm"; -import { decrypt, encrypt } from "../utils/crypt"; - -interface EncryptedTextTransformer extends ValueTransformer { - from(dbValue: any): string; - to(entityValue: string): any; -} - -export function createEncryptedTextTransformer(): EncryptedTextTransformer { - return { - // Database -> Entity - from(dbValue) { - return decrypt(dbValue); - }, - - // Entity -> Database - to(entityValue) { - return encrypt(entityValue); - }, - }; -} diff --git a/backend/src/data/entities/ArchiveEntry.ts b/backend/src/data/entities/ArchiveEntry.ts index 639ef9bb..68181d02 100644 --- a/backend/src/data/entities/ArchiveEntry.ts +++ b/backend/src/data/entities/ArchiveEntry.ts @@ -1,5 +1,4 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { createEncryptedTextTransformer } from "../encryptedTextTransformer"; @Entity("archives") export class ArchiveEntry { @@ -11,7 +10,6 @@ export class ArchiveEntry { @Column({ type: "mediumtext", - transformer: createEncryptedTextTransformer(), }) body: string; diff --git a/backend/src/data/entities/SavedMessage.ts b/backend/src/data/entities/SavedMessage.ts index 013dba80..5456970b 100644 --- a/backend/src/data/entities/SavedMessage.ts +++ b/backend/src/data/entities/SavedMessage.ts @@ -1,6 +1,5 @@ import { Snowflake } from "discord.js"; import { Column, Entity, PrimaryColumn } from "typeorm"; -import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer"; export interface ISavedMessageAttachmentData { id: Snowflake; @@ -93,7 +92,6 @@ export class SavedMessage { @Column({ type: "mediumtext", - transformer: createEncryptedJsonTransformer(), }) data: ISavedMessageData; diff --git a/backend/src/migrations/1600283341726-EncryptExistingMessages.ts b/backend/src/migrations/1600283341726-EncryptExistingMessages.ts index f54ecae1..19a21fca 100644 --- a/backend/src/migrations/1600283341726-EncryptExistingMessages.ts +++ b/backend/src/migrations/1600283341726-EncryptExistingMessages.ts @@ -9,7 +9,7 @@ export class EncryptExistingMessages1600283341726 implements MigrationInterface // 2. Encrypt all permanent messages const messages = await queryRunner.query("SELECT id, data FROM messages"); for (const message of messages) { - const encryptedData = encrypt(message.data); + const encryptedData = await encrypt(message.data); await queryRunner.query("UPDATE messages SET data = ? WHERE id = ?", [encryptedData, message.id]); } } @@ -18,7 +18,7 @@ export class EncryptExistingMessages1600283341726 implements MigrationInterface // Decrypt all messages const messages = await queryRunner.query("SELECT id, data FROM messages"); for (const message of messages) { - const decryptedData = decrypt(message.data); + const decryptedData = await decrypt(message.data); await queryRunner.query("UPDATE messages SET data = ? WHERE id = ?", [decryptedData, message.id]); } } diff --git a/backend/src/migrations/1600285077890-EncryptArchives.ts b/backend/src/migrations/1600285077890-EncryptArchives.ts index 66bb20aa..52d6393b 100644 --- a/backend/src/migrations/1600285077890-EncryptArchives.ts +++ b/backend/src/migrations/1600285077890-EncryptArchives.ts @@ -5,7 +5,7 @@ export class EncryptArchives1600285077890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { const archives = await queryRunner.query("SELECT id, body FROM archives"); for (const archive of archives) { - const encryptedBody = encrypt(archive.body); + const encryptedBody = await encrypt(archive.body); await queryRunner.query("UPDATE archives SET body = ? WHERE id = ?", [encryptedBody, archive.id]); } } @@ -13,7 +13,7 @@ export class EncryptArchives1600285077890 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { const archives = await queryRunner.query("SELECT id, body FROM archives"); for (const archive of archives) { - const decryptedBody = decrypt(archive.body); + const decryptedBody = await decrypt(archive.body); await queryRunner.query("UPDATE archives SET body = ? WHERE id = ?", [decryptedBody, archive.id]); } } diff --git a/backend/src/utils/crypt.test.ts b/backend/src/utils/crypt.test.ts index 54417c8b..c9652a97 100644 --- a/backend/src/utils/crypt.test.ts +++ b/backend/src/utils/crypt.test.ts @@ -1,9 +1,9 @@ import test from "ava"; import { decrypt, encrypt } from "./crypt"; -test("encrypt() followed by decrypt()", (t) => { +test("encrypt() followed by decrypt()", async (t) => { const original = "banana 123 👀 💕"; // Includes emojis to verify utf8 stuff works - const encrypted = encrypt(original); - const decrypted = decrypt(encrypted); + const encrypted = await encrypt(original); + const decrypted = await decrypt(encrypted); t.is(decrypted, original); }); diff --git a/backend/src/utils/crypt.ts b/backend/src/utils/crypt.ts index fdf81109..1d428150 100644 --- a/backend/src/utils/crypt.ts +++ b/backend/src/utils/crypt.ts @@ -1,5 +1,6 @@ -import crypto from "crypto"; +import { spawn, Worker } from "threads"; import "../loadEnv"; +import type { CryptFns } from "./cryptWorker"; if (!process.env.KEY) { // tslint:disable-next-line:no-console @@ -8,27 +9,19 @@ if (!process.env.KEY) { } const KEY = process.env.KEY; -const ALGORITHM = "aes-256-gcm"; +let workerPromise: Promise | null = null; -export function encrypt(str) { - // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 - - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv); - - let encrypted = cipher.update(str, "utf8", "base64"); - encrypted += cipher.final("base64"); - return `${iv.toString("base64")}.${cipher.getAuthTag().toString("base64")}.${encrypted}`; +async function getWorker(): Promise { + if (workerPromise == null) { + workerPromise = spawn(new Worker("./cryptWorker")) as unknown as Promise; + } + return workerPromise; } -export function decrypt(encrypted) { - // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 - - const [iv, authTag, encryptedStr] = encrypted.split("."); - const decipher = crypto.createDecipheriv(ALGORITHM, KEY, Buffer.from(iv, "base64")); - decipher.setAuthTag(Buffer.from(authTag, "base64")); - - let decrypted = decipher.update(encryptedStr, "base64", "utf8"); - decrypted += decipher.final("utf8"); - return decrypted; +export async function encrypt(data: string) { + return (await getWorker()).encrypt(data, KEY); +} + +export async function decrypt(data: string) { + return (await getWorker()).decrypt(data, KEY); } diff --git a/backend/src/utils/cryptHelpers.ts b/backend/src/utils/cryptHelpers.ts new file mode 100644 index 00000000..209ef0f6 --- /dev/null +++ b/backend/src/utils/cryptHelpers.ts @@ -0,0 +1,11 @@ +import { decrypt, encrypt } from "./crypt"; + +export async function encryptJson(obj: any): Promise { + const serialized = JSON.stringify(obj); + return encrypt(serialized); +} + +export async function decryptJson(encrypted: string): Promise { + const decrypted = await decrypt(encrypted); + return JSON.parse(decrypted); +} diff --git a/backend/src/utils/cryptWorker.ts b/backend/src/utils/cryptWorker.ts new file mode 100644 index 00000000..be5e6df0 --- /dev/null +++ b/backend/src/utils/cryptWorker.ts @@ -0,0 +1,31 @@ +import crypto from "crypto"; +import { expose } from "threads"; + +const ALGORITHM = "aes-256-gcm"; + +function encrypt(str, key) { + // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(str, "utf8", "base64"); + encrypted += cipher.final("base64"); + return `${iv.toString("base64")}.${cipher.getAuthTag().toString("base64")}.${encrypted}`; +} + +function decrypt(encrypted, key) { + // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 + + const [iv, authTag, encryptedStr] = encrypted.split("."); + const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, "base64")); + decipher.setAuthTag(Buffer.from(authTag, "base64")); + + let decrypted = decipher.update(encryptedStr, "base64", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +} + +const toExpose = { encrypt, decrypt }; +expose(toExpose); +export type CryptFns = typeof toExpose;