Merge branch 'security' into security

This commit is contained in:
Mega-Mewthree 2018-08-13 10:35:16 -07:00 committed by GitHub
commit 81c451d31f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 595 additions and 99 deletions

View File

@ -8,33 +8,34 @@
* LICENSE file in the root directory of this source tree.
*/
import { Settings } from 'modules';
import { Settings, Cache } from 'modules';
import BuiltinModule from './BuiltinModule';
import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi } from 'modules';
import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi, Security } from 'modules';
import { VueInjector, Reflection } from 'ui';
import { ClientLogger as Logger } from 'common';
import { request } from 'vendor';
import { Utils } from 'common';
import E2EEComponent from './E2EEComponent.vue';
import E2EEMessageButton from './E2EEMessageButton.vue';
import aes256 from 'aes256';
import crypto from 'crypto';
import nodecrypto from 'node-crypto';
const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, "g");
const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, "g");
const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`);
let seed = Math.random().toString(36).replace(/[^a-z]+/g, '');
const TEMP_KEY = 'temporarymasterkey';
let seed;
export default new class E2EE extends BuiltinModule {
constructor() {
super();
this.master = this.encrypt(seed, 'temporarymasterkey');
this.encryptNewMessages = true;
}
setMaster(key) {
seed = Math.random().toString(36).replace(/[^a-z]+/g, '');
const newMaster = this.encrypt(seed, key);
seed = Security.randomBytes();
const newMaster = Security.encrypt(seed, key);
// TODO re-encrypt everything with new master
return (this.master = newMaster);
}
@ -49,11 +50,28 @@ export default new class E2EE extends BuiltinModule {
}
encrypt(key, content, prefix = '') {
return prefix + aes256.encrypt(key, content);
if (!key) {
// Encrypt something with master
return Security.encrypt(Security.decrypt(seed, this.master), content);
}
if (!content) {
// Get key for current channel and encrypt
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return 'nokey';
return Security.encrypt(Security.decrypt(seed, [this.master, haveKey]), key);
}
return prefix + Security.encrypt(key, content);
}
decrypt(key, content, prefix = '') {
return aes256.decrypt(key, content.replace(prefix, ''));
return Security.decrypt(key, content, prefix);
}
async createHmac(data) {
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return null;
return Security.createHmac(Security.decrypt(seed, [this.master, haveKey]), data);
}
getKey(channelId) {
@ -73,6 +91,9 @@ export default new class E2EE extends BuiltinModule {
}
async enabled(e) {
seed = Security.randomBytes();
// TODO Input modal for key
this.master = Security.encrypt(seed, TEMP_KEY);
this.patchDispatcher();
this.patchMessageContent();
const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton');
@ -119,6 +140,8 @@ export default new class E2EE extends BuiltinModule {
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector });
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).before('render', this.beforeRenderMessageContent.bind(this));
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).after('render', this.renderMessageContent.bind(this));
const ImageWrapper = await ReactComponents.getComponent('ImageWrapper', { selector: '.' + WebpackModules.getClassName('imageWrapper') });
MonkeyPatch('BD:E2EE', ImageWrapper.component.prototype).before('render', this.beforeRenderImageWrapper.bind(this));
}
beforeRenderMessageContent(component) {
@ -137,7 +160,7 @@ export default new class E2EE extends BuiltinModule {
if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string
let decrypt;
try {
decrypt = this.decrypt(this.decrypt(this.decrypt(seed, this.master), key), component.props.message.content);
decrypt = Security.decrypt(seed, [this.master, key, component.props.message.content]);
} catch (err) { return } // Ignore errors such as non empty
component.props.message.bd_encrypted = true; // signal as encrypted
@ -166,10 +189,77 @@ export default new class E2EE extends BuiltinModule {
renderMessageContent(component, args, retVal) {
if (!component.props.message.bd_encrypted) return;
try {
retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton));
} catch (err) {
Logger.err('E2EE', err.message);
}
}
retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton, {
message: component.props.message
}));
beforeRenderImageWrapper(component, args, retVal) {
if (!component.props || !component.props.src) return;
if (component.props.decrypting) return;
component.props.decrypting = true;
const src = component.props.original || component.props.src.split('?')[0];
if (!src.includes('bde2ee')) return;
component.props.className = 'bd-encryptedImage';
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return;
const cached = Cache.find('e2ee:images', item => item.src === src);
if (cached) {
if (cached.invalidKey) { // TODO If key has changed we should recheck all with invalid key
component.props.className = 'bd-encryptedImage bd-encryptedImageBadKey';
component.props.readyState = 'READY';
return;
}
Logger.info('E2EE', 'Returning encrypted image from cache');
try {
const decrypt = Security.decrypt(seed, [this.master, haveKey, cached.image]);
component.props.className = 'bd-decryptedImage';
component.props.src = component.props.original = 'data:;base64,' + decrypt;
} catch (err) { return } finally { component.props.readyState = 'READY' }
return;
}
component.props.readyState = 'LOADING';
Logger.info('E2EE', 'Decrypting image: ' + src);
request.get(src, { encoding: 'binary' }).then(res => {
(async () => {
const arr = new Uint8Array(new ArrayBuffer(res.length));
for (let i = 0; i < res.length; i++) arr[i] = res.charCodeAt(i);
const aobindex = Utils.aobscan(arr, [73, 69, 78, 68]) + 8;
const sliced = arr.slice(aobindex);
const image = new TextDecoder().decode(sliced);
const hmac = image.slice(-64);
const data = image.slice(0, -64);
const validateHmac = await this.createHmac(data);
if (hmac !== validateHmac) {
Cache.push('e2ee:images', { src, invalidKey: true });
if (component && component.props) {
component.props.decrypting = false;
component.forceUpdate();
}
return;
}
Cache.push('e2ee:images', { src, image: data });
if (!component || !component.props) {
Logger.warn('E2EE', 'Component seems to be gone');
return;
}
component.props.decrypting = false;
component.forceUpdate();
})();
}).catch(err => {
console.log('request error', err);
});
}
patchChannelTextArea(cta) {
@ -215,7 +305,7 @@ export default new class E2EE extends BuiltinModule {
handleChannelTextAreaSubmit(component, args, retVal) {
const key = this.getKey(DiscordApi.currentChannel.id);
if (!this.encryptNewMessages || !key) return;
component.props.value = this.encrypt(this.decrypt(this.decrypt(seed, this.master), key), component.props.value, '$:');
component.props.value = Security.encrypt(Security.decrypt(seed, [this.master, key]), component.props.value, '$:');
}
async disabled(e) {

View File

@ -10,19 +10,26 @@
<template>
<div class="bd-e2eeTaContainer" @contextmenu.prevent="channelType === 'DM' && $refs.ee2eLockContextMenu.open()">
<div v-if="error" class="bd-e2eeTaBtn bd-e2eeLock bd-error">
<MiLock v-tooltip="error" />
</div>
<div v-else-if="state === 'loading'" class="bd-e2eeTaBtn bd-e2eeLock bd-loading bd-warn">
<MiLock v-tooltip="'Loading'" />
</div>
<div v-else-if="!E2EE.encryptNewMessages" class="bd-e2eeTaBtn bd-e2eeLock bd-warn" @click="E2EE.encryptNewMessages = true">
<MiLock v-tooltip="'New messages will not be encrypted.'" />
</div>
<div v-else class="bd-e2eeTaBtn bd-e2eeLock bd-ok" @click="E2EE.encryptNewMessages = false">
<MiLock v-tooltip="'Ready!'" />
</div>
<v-popover popoverClass="bd-popover bd-e2eePopover" placement="top">
<div v-if="error" class="bd-e2eeTaBtn bd-e2eeLock bd-error">
<MiLock v-tooltip="error" />
</div>
<div v-else-if="state === 'loading'" class="bd-e2eeTaBtn bd-e2eeLock bd-loading bd-warn">
<MiLock v-tooltip="'Loading'" />
</div>
<div v-else-if="!E2EE.encryptNewMessages" class="bd-e2eeTaBtn bd-e2eeLock bd-warn">
<MiLock v-tooltip="'New messages will not be encrypted.'" />
</div>
<div v-else class="bd-e2eeTaBtn bd-e2eeLock bd-ok">
<MiLock v-tooltip="'Ready!'" />
</div>
<template slot="popover">
<div @click="toggleEncrypt" :class="{'bd-warn': !E2EE.encryptNewMessages, 'bd-ok': E2EE.encryptNewMessages}"><MiLock size="16" v-tooltip="'Toggle Encryption'" /></div>
<div v-close-popover @click="showUploadDialog" v-if="!error"><MiImagePlus size="16" v-tooltip="'Upload Encrypted Image'" /></div>
</template>
</v-popover>
<div class="bd-taDivider"></div>
<context-menu id="bd-e2eeLockContextMenu" class="bd-e2eeLockContextMenu" ref="ee2eLockContextMenu" v-if="channelType === 'DM'">
<li class="bd-e2eeLockContextMenuOption" @click="generatePublicKey()">Generate Public Key</li>
@ -31,42 +38,20 @@
</div>
</template>
<script>
import fs from 'fs';
import { Utils } from 'common';
import { remote } from 'electron';
import { E2EE } from 'builtin';
import { DiscordApi } from 'modules';
import { MiLock } from '../ui/components/common/MaterialIcon';
import { DiscordApi, Security } from 'modules';
import { MiLock, MiPlus, MiImagePlus } from '../ui/components/common/MaterialIcon';
import contextMenu from 'vue-context-menu';
import { clipboard } from 'electron';
import { Toasts } from 'ui';
function generatePublicKey() {
const dmChannelID = location.pathname.split("/")[3];
const publicKeyMessage = `My public key is: \`${E2EE.createKeyExchange(dmChannelID)}\`. Please give me your public key if you haven't done so and add my public key by pasting it in the chat textbox, right clicking the lock icon, and selecting \`Receive Public Key\`.`;
const chatInput = document.getElementsByClassName('da-textArea')[0];
chatInput.value = publicKeyMessage;
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
}
function computeSharedSecret() {
try {
const dmChannelID = location.pathname.split("/")[3];
const chatInput = document.getElementsByClassName('da-textArea')[0];
const otherPublicKey = chatInput.value;
const secret = E2EE.computeSecret(dmChannelID, otherPublicKey);
E2EE.setKey(dmChannelID, secret);
chatInput.value = "";
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
Toasts.success("Encryption key has been set for this DM channel.");
} catch (e) {
Toasts.error("Invalid public key. Please set up a new key exchange.");
console.error(e);
}
}
export default {
components: { MiLock, contextMenu },
components: { MiLock, MiPlus, MiImagePlus },
data() {
return {
E2EE,
@ -75,7 +60,60 @@
channelType: DiscordApi.currentChannel.type
};
},
methods: { generatePublicKey, computeSharedSecret },
methods: {
async showUploadDialog() {
const dialogResult = remote.dialog.showOpenDialog({ properties: ['openFile'] });
if (!dialogResult) return;
const readFile = fs.readFileSync(dialogResult[0]);
const FileActions = _bd.WebpackModules.getModuleByProps(["makeFile"]);
const Uploader = _bd.WebpackModules.getModuleByProps(["instantBatchUpload"]);
const img = await Utils.getImageFromBuffer(readFile);
const canvas = document.createElement('canvas');
canvas.height = img.height;
canvas.width = img.width;
const arrBuffer = await Utils.canvasToArrayBuffer(canvas);
const encrypted = E2EE.encrypt(img.src.replace('data:;base64,', ''));
const hmac = await E2EE.createHmac(encrypted);
const encodedBytes = new TextEncoder().encode(encrypted + hmac);
Uploader.upload(DiscordApi.currentChannel.id, FileActions.makeFile(new Uint8Array([...new Uint8Array(arrBuffer), ...encodedBytes]), 'bde2ee.png'));
},
toggleEncrypt() {
const newState = !E2EE.encryptNewMessages;
E2EE.encryptNewMessages = newState;
if (!newState) {
Toasts.warning('New messages will not be encrypted');
return;
}
Toasts.success('New messages will be encrypted');
},
generatePublicKey() {
const dmChannelID = location.pathname.split("/")[3];
const publicKeyMessage = `My public key is: \`${E2EE.createKeyExchange(dmChannelID)}\`. Please give me your public key if you haven't done so and add my public key by pasting it in the chat textbox, right clicking the lock icon, and selecting \`Receive Public Key\`.`;
const chatInput = document.getElementsByClassName('da-textArea')[0];
chatInput.value = publicKeyMessage;
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
},
computeSharedSecret() {
try {
const dmChannelID = location.pathname.split("/")[3];
const chatInput = document.getElementsByClassName('da-textArea')[0];
const otherPublicKey = chatInput.value;
const secret = E2EE.computeSecret(dmChannelID, otherPublicKey);
E2EE.setKey(dmChannelID, secret);
chatInput.value = "";
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
Toasts.success("Encryption key has been set for this DM channel.");
} catch (e) {
Toasts.error("Invalid public key. Please set up a new key exchange.");
console.error(e);
}
}
},
mounted() {
if (!E2EE.master) {
this.error = 'No master key set!';

View File

@ -37,7 +37,7 @@ export default new class VueDevtoolsModule extends BuiltinModule {
}
devToolsOpened() {
electron.remote.BrowserWindow.removeDevToolsExtension('Vue.js devtools');
electron.remote.BrowserWindow.removeDevToolsExtension('Vue.js devtools');
electron.webFrame.registerURLSchemeAsPrivileged('chrome-extension');
try {
const res = electron.remote.BrowserWindow.addDevToolsExtension(path.join(Globals.getPath('ext'), 'extensions', 'vdt'));

View File

@ -93,6 +93,13 @@
"hint": "When this is enabled you can use Ctrl/Cmd + B to open the BetterDiscord settings menu.",
"value": false,
"disabled": false
},
{
"id": "enable-toasts",
"type": "bool",
"text": "Enable Toasts",
"hint": "Allows plugins to show toasts.",
"value": true
}
]
}
@ -144,7 +151,7 @@
{
"id": "security",
"text": "Security and Privacy",
"headertext": "Security Settings",
"headertext": "Security and Privacy Settings",
"settings": [
{
"id": "default",

View File

@ -10,7 +10,7 @@
import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts } from 'ui';
import BdCss from './styles/index.scss';
import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi, BdWebApi, Connectivity } from 'modules';
import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi, BdWebApi, Connectivity, Cache } from 'modules';
import { ClientLogger as Logger, ClientIPC, Utils } from 'common';
import { BuiltinManager, EmoteModule, ReactDevtoolsModule, VueDevtoolsModule, TrackingProtection, E2EE } from 'builtin';
import electron from 'electron';
@ -37,6 +37,7 @@ class BetterDiscord {
EmoteModule,
BdWebApi,
Connectivity,
Cache,
Logger, ClientIPC, Utils,
plugins: PluginManager.localContent,

View File

@ -0,0 +1,38 @@
/**
* BetterDiscord Cache Module
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const CACHE = [];
export default class Cache {
static get cache() {
return CACHE;
}
/**
* Push something to cache
* @param {String} where Cache identifier
* @param {any} data Data to push
*/
static push(where, data) {
if (!this.cache[where]) this.cache[where] = [];
this.cache[where].push(data);
}
/**
* Find something in cache
* @param {String} where Cache identifier
* @param {Function} what Find callback
*/
static find(where, what) {
if (!this.cache[where]) this.cache[where] = [];
return this.cache[where].find(what);
}
}

View File

@ -25,3 +25,4 @@ export { default as DiscordApi, Modules as DiscordApiModules } from './discordap
export { default as BdWebApi } from './bdwebapi';
export { default as Connectivity } from './connectivity';
export { default as Security } from './security';
export { default as Cache } from './cache';

View File

@ -18,9 +18,9 @@ import { ClientLogger as Logger } from 'common';
/**
* A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely.
*
*
* The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches.
*
*
* @callback Patcher~patchCallback
* @param {object} thisObject - `this` in the context of the original function.
* @param {arguments} arguments - The original arguments of the original function.
@ -42,8 +42,8 @@ export class Patcher {
const patches = [];
for (const patch of this.patches) {
for (const childPatch of patch.children) {
if (childPatch.caller === id) patches.push(childPatch);
}
if (childPatch.caller === id) patches.push(childPatch);
}
}
return patches;
}
@ -135,7 +135,7 @@ export class Patcher {
/**
* This method patches onto another function, allowing your code to run beforehand.
* Using this, you are also able to modify the incoming arguments before the original method is run.
*
*
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
@ -148,7 +148,7 @@ export class Patcher {
/**
* This method patches onto another function, allowing your code to run afterwards.
* Using this, you are also able to modify the return value, using the return of your code instead.
*
*
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
@ -161,7 +161,7 @@ export class Patcher {
/**
* This method patches onto another function, allowing your code to run instead, preventing the running of the original code.
* Using this, you are also able to modify the return value, using the return of your code instead.
*
*
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
@ -175,7 +175,7 @@ export class Patcher {
* This method patches onto another function, allowing your code to run before, instead or after the original function.
* Using this you are able to modify the incoming arguments before the original function is run as well as the return
* value before the original function actually returns.
*
*
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
@ -201,10 +201,10 @@ export class Patcher {
unpatch: () => {
patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1);
if (patch.children.length <= 0) {
const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName);
this.patches[patchNum].revert();
this.patches.splice(patchNum, 1);
}
const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName);
this.patches[patchNum].revert();
this.patches.splice(patchNum, 1);
}
}
};
patch.children.push(child);

View File

@ -330,7 +330,8 @@ export default class PluginApi {
success: this.showSuccessToast.bind(this),
error: this.showErrorToast.bind(this),
info: this.showInfoToast.bind(this),
warning: this.showWarningToast.bind(this)
warning: this.showWarningToast.bind(this),
get enabled() { return Toasts.enabled }
};
}

View File

@ -8,21 +8,25 @@
* LICENSE file in the root directory of this source tree.
*/
import nodecrypto from 'node-crypto';
import aes256 from 'aes256';
export default class Security {
static encrypt(key, content, prefix = '') {
if (key instanceof Array) return this.deepEncrypt(key, content, prefix);
if (key instanceof Array || content instanceof Array) return this.deepEncrypt(key, content, prefix);
return `${prefix}${aes256.encrypt(key, content)}`;
}
static decrypt(key, content, prefix = '') {
if (key instanceof Array) return this.deepDecrypt(key, content, prefix);
if (key instanceof Array || content instanceof Array) {
return this.deepDecrypt(key, content, prefix);
}
return aes256.decrypt(key, content.replace(prefix, ''));
}
static deepEncrypt(keys, content, prefix = '') {
if (content && content instanceof Array) return this.deepEncryptContent(keys, content, prefix);
let encrypt = null;
for (const key of keys) {
if (encrypt === null) encrypt = this.encrypt(key, content, prefix);
@ -31,7 +35,17 @@ export default class Security {
return encrypt;
}
static deepEncryptContent(key, contents, prefix = '') {
let encrypt = null;
for (const content of contents) {
if (encrypt === null) encrypt = this.encrypt(key, content, prefix);
else encrypt = this.encrypt(encrypt, content, prefix);
}
return encrypt;
}
static deepDecrypt(keys, content, prefix = '') {
if (content && content instanceof Array) return this.deepDecryptContent(keys, content, prefix);
let decrypt = null;
for (const key of keys.reverse()) {
if (decrypt === null) decrypt = this.decrypt(key, content, prefix);
@ -40,4 +54,30 @@ export default class Security {
return decrypt;
}
static deepDecryptContent(key, contents, prefix = '') {
let decrypt = null;
for (const content of contents) {
if (decrypt === null) decrypt = this.decrypt(key, content, prefix);
else decrypt = this.decrypt(decrypt, content, prefix);
}
return decrypt;
}
static randomBytes(length = 64, to = 'hex') {
return nodecrypto.randomBytes(length).toString(to);
}
static async createHmac(key, data, algorithm = 'sha256') {
const hmac = nodecrypto.createHmac(algorithm, key);
return new Promise((resolve, reject) => {
hmac.on('readable', () => {
const data = hmac.read();
if (data) return resolve(data.toString('hex'));
reject(null);
});
hmac.write(data);
hmac.end();
});
}
}

View File

@ -11,10 +11,11 @@
import Kvp from './kvp';
export default class SecureKvpSetting extends Kvp {
/**
* The value to use when the setting doesn't have a value.
*/
get defaultValue() {
return { key: 'Key', value: '**********' };
return { key: 'PlaceholderKey', value: '' };
}
}

View File

@ -44,11 +44,14 @@
}
&.bd-hide-button {
transition: opacity 0.4s ease-out;
opacity: 0;
animation: bd-fade-out 0.4s ease-out;
&.bd-active {
transition-timing-function: ease-in;
animation: bd-fade-in 0.4s ease-in;
}
&:not(.bd-active):not(.bd-animating) {
display: none;
}
}

View File

@ -16,7 +16,7 @@
&.bd-e2eeLock {
&.bd-error {
fill: #cc3e3e;
fill: $colerr;
}
&.bd-ok {
@ -24,7 +24,7 @@
}
&.bd-warn {
fill: #ccab3e;
fill: $colwarn;
animation: bd-pulse 2s ease-in-out infinite;
}
}
@ -75,7 +75,7 @@
&.bd-e2eeLock {
&.bd-error {
fill: #cc3e3e;
fill: $colerr;
}
&.bd-ok {
@ -83,7 +83,7 @@
}
&.bd-warn {
fill: #ccab3e;
fill: $colwarn;
}
&.bd-loading {
@ -106,3 +106,87 @@
}
}
}
.bd-e2eePopover {
background: #484b51;
margin: 0;
margin-top: 15px;
.bd-ok svg {
fill: $colbdgreen;
}
.bd-warn svg {
fill: $colwarn;
}
.bd-popover-wrapper,
.bd-popoverWrapper {
.bd-popover-inner,
.bd-popoverInner {
display: flex;
> div:first-child {
display: flex;
> div:not(:first-child) {
margin-left: 10px;
}
}
.bd-material-design-icon,
.bd-materialDesignIcon {
display: flex;
fill: #7e8084;
cursor: pointer;
&:hover {
fill: #FFF;
}
}
}
}
.bd-popover-arrow,
.bd-popoverArrow {
display: none;
}
}
.bd-encryptedImage::before {
content: "";
position: absolute;
background: $colbdgreen;
width: 100%;
height: 100%;
border-radius: 3px;
display: flex;
// justify-content: center;
// align-items: flex-start;
// font-size: 1.2em;
// font-weight: 700;
// color: #2c2c2c;
// line-height: 30px;
background-image: $lockIcon;
background-size: calc(100% / 2);
background-repeat: no-repeat;
background-position: center;
}
.bd-decryptedImage::before {
content: "";
background-image: $lockIcon;
width: 11px;
height: 11px;
position: absolute;
z-index: 1;
display: block;
background-size: cover;
background-color: $colbdgreen;
background-repeat: no-repeat;
border-radius: 100%;
border: 2px solid $colbdgreen;
top: 5px;
left: 5px;
opacity: .5;
}

View File

@ -0,0 +1,65 @@
.bd-formCollection {
display: flex;
flex-direction: column;
> :first-child {
flex: 1 1 auto;
}
.bd-collectionItem {
display: flex;
flex-grow: 1;
margin-top: 5px;
> :first-child {
flex: 1 0 auto;
}
.bd-removeCollectionItem {
width: 20px;
flex: 0 1 auto;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
svg {
width: 16px;
height: 16px;
fill: #ccc;
}
&:not(.bd-disabled):hover {
svg {
fill: #fff;
}
}
&.bd-disabled {
opacity: 0.5;
}
}
}
.bd-newCollectionItem {
display: flex;
cursor: pointer;
align-self: flex-end;
justify-content: center;
align-items: center;
margin-right: 2px;
margin-top: 10px;
svg {
width: 16px;
height: 16px;
fill: #ccc;
}
&:hover {
svg {
fill: #FFF;
}
}
}
}

View File

@ -9,3 +9,4 @@
@import './files.scss';
@import './guilds.scss';
@import './arrays.scss';
@import './collections.scss';

View File

@ -3,5 +3,5 @@ $colbdgreen: #3ecc9c;
$colbdblue: $colbdgreen;
$colerr: #d84040;
$colwarn: #faa61a;
$colok: #43b581;
$colok: $colbdgreen;
$coldimwhite: #b9bbbe;

File diff suppressed because one or more lines are too long

View File

@ -65,8 +65,7 @@ export default class ClassNormaliser extends Module {
normalizeElement(element) {
if (!(element instanceof Element)) return;
if (element.children && element.children.length) this.normalizeElement(element.children[0]);
if (element.nextElementSibling) this.normalizeElement(element.nextElementSibling);
const classes = element.classList;
for (let c = 0, clen = classes.length; c < clen; c++) {
if (!randClass.test(classes[c])) continue;
@ -74,6 +73,10 @@ export default class ClassNormaliser extends Module {
const newClass = match.split('-').map((s, i) => i ? s[0].toUpperCase() + s.slice(1) : s).join('');
element.classList.add(`${normalizedPrefix}-${newClass}`);
}
for (let child of element.children) {
this.normalizeElement(child);
}
}
}

View File

@ -47,7 +47,7 @@
methods: {
keyupListener(e) {
if (Modals.stack.length || !this.active || e.which !== 27) return;
if (this.$refs.settings.activeIndex !== -1) this.$refs.settings.closeContent();
if (this.$refs.settings.item) this.$refs.settings.closeContent();
else this.active = false;
e.stopImmediatePropagation();
},

View File

@ -11,10 +11,10 @@
<template>
<div class="bd-formCollection">
<div v-for="s in setting.items" class="bd-collectionItem">
<Setting :setting="s" :key="s.id" />
<div class="bd-removeCollectionItem" @click="removeItem(s)"><MiMinus/></div>
<Setting :setting="s" :hide-divider="true" :key="s.id" />
<div class="bd-removeCollectionItem" :class="{'bd-disabled': setting.disabled || setting.min && setting.items.length <= setting.min}" @click="removeItem(s)"><MiMinus/></div>
</div>
<div class="bd-newCollectionItem" @click="addItem"><MiPlus/></div>
<div v-if="!setting.disabled && !setting.max || setting.items.length < setting.max" class="bd-newCollectionItem" @click="addItem"><MiPlus/></div>
</div>
</template>
@ -29,9 +29,11 @@
},
methods: {
removeItem(item) {
if (this.setting.disabled || this.setting.min && this.setting.items.length <= this.setting.min) return;
this.setting.removeItem(item);
},
addItem() {
if (this.setting.disabled || this.setting.max && this.setting.items.length >= this.setting.max) return;
this.setting.addItem();
}
},

View File

@ -22,11 +22,12 @@
</template>
<script>
import aes256 from 'aes256';
import { DiscordApi } from 'modules';
import { E2EE } from 'builtin';
export default {
data() {
return {
masterKey: 'temporarymasterkey',
valueChanged: false
}
},
@ -40,7 +41,7 @@
},
valueBlur(e) {
if (!this.valueChanged) return;
const value = aes256.encrypt(this.masterKey, e.target.value);
const value = E2EE.encrypt(null, e.target.value);
this.setting.value = { key: this.setting.value.key, value }
this.valueChanged = false;
},
@ -52,6 +53,10 @@
if (e.key !== 'Enter') return;
e.target.blur();
}
},
beforeMount() {
if (this.setting.value.key !== 'PlaceholderKey') return;
this.setting.value.key = DiscordApi.currentChannel.id || 'Key';
}
}
</script>

View File

@ -26,7 +26,8 @@
<KeyValuePair v-else-if="setting.type === 'kvp'" :setting="setting" />
<SecureKeyValuePair v-else-if="setting.type === 'securekvp'" :setting="setting" />
<CustomSetting v-else-if="setting.type === 'custom'" :setting="setting" />
<div class="bd-form-divider"></div>
<div v-if="!hideDivider" class="bd-form-divider"></div>
</div>
</template>
@ -51,7 +52,8 @@
export default {
props: [
'setting'
'setting',
'hide-divider'
],
components: {
BoolSetting,

View File

@ -19,3 +19,4 @@ export { default as MiWarning } from './materialicons/Warning.vue';
export { default as MiSuccess } from './materialicons/Success.vue';
export { default as AccountCircle } from './materialicons/AccountCircle.vue';
export { default as MiLock } from './materialicons/Lock.vue';
export { default as MiImagePlus } from './materialicons/ImagePlus.vue';

View File

@ -0,0 +1,27 @@
/**
* BetterDiscord Image Plus Icon
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Material Design Icons
* Copyright (c) 2014 Google
* Apache 2.0 LICENSE
* https://www.apache.org/licenses/LICENSE-2.0.txt
*/
<template>
<span class="bd-material-design-icon">
<svg :width="size || 24" :height="size || 24" viewBox="0 0 24 24">
<path d="M 5,3C 3.89543,3 3,3.8954 3,5L 3,19C 3,20.1046 3.89543,21 5,21L 14.0859,21C 14.0294,20.6696 14.0007,20.3351 14,20C 14.0027,19.3182 14.1216,18.6418 14.3516,18L 5,18L 8.5,13.5L 11,16.5L 14.5,12L 16.7285,14.9727C 17.7019,14.3386 18.8383,14.0007 20,14C 20.3353,14.002 20.6698,14.032 21,14.0898L 21,5C 21,3.89 20.1,3 19,3L 5,3 Z M 19,16L 19,19L 16,19L 16,21L 19,21L 19,24L 21,24L 21,21L 24,21L 24,19L 21,19L 21,16L 19,16 Z " />
</svg>
</span>
</template>
<script>
export default {
props: ['size']
}
</script>

View File

@ -8,6 +8,8 @@
* LICENSE file in the root directory of this source tree.
*/
import { Settings } from 'modules';
let toasts = 0;
export default class Toasts {
@ -24,6 +26,8 @@ export default class Toasts {
* @returns {Promise} This promise resolves when the toast is removed from the DOM.
*/
static async push(message, options = {}) {
if (!this.enabled) return;
const {type = 'basic', icon, additionalClasses, timeout = 3000} = options;
const toast = {id: toasts++, message, type, icon, additionalClasses, closing: false};
this.stack.push(toast);
@ -72,4 +76,16 @@ export default class Toasts {
return this._stack || (this._stack = []);
}
static get setting() {
return Settings.getSetting('ui', 'default', 'enable-toasts');
}
static get enabled() {
return this.setting.value;
}
static set enabled(enabled) {
this.setting.value = enabled;
}
}

View File

@ -40,7 +40,7 @@ module.exports = {
crypto: 'require("crypto")',
request: 'require(require("path").join(require("electron").remote.app.getAppPath(), "node_modules", "request"))',
sparkplug: 'require("../../core/dist/sparkplug")',
crypto: 'require("crypto")'
'node-crypto': 'require("crypto")'
},
resolve: {
alias: {

View File

@ -176,6 +176,69 @@ export class Utils {
} while (!value);
return value;
}
/**
* Finds the index of array of bytes in another array
* @param {Array} haystack The array to find aob in
* @param {Array} needle The aob to find
* @return {Number} aob index, -1 if not found
*/
static aobscan(haystack, needle) {
for (let h = 0; h < haystack.length - needle.length + 1; ++h) {
let found = true;
for (let n = 0; n < needle.length; ++n) {
if (needle[n] === null ||
needle[n] === '??' ||
haystack[h + n] === needle[n]) continue;
found = false;
break;
}
if (found) return h;
}
return -1;
}
/**
* Convert buffer to base64 encoded string
* @param {any} buffer buffer to convert
* @returns {String} base64 encoded string from buffer
*/
static arrayBufferToBase64(buffer) {
let binary = '';
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
static async getImageFromBuffer(buffer) {
if (!(buffer instanceof Blob)) buffer = new Blob([buffer]);
const reader = new FileReader();
reader.readAsDataURL(buffer);
await new Promise(r => {
reader.onload = r
});
const img = new Image();
img.src = reader.result;
return await new Promise(resolve => {
img.onload = () => {
resolve(img);
}
});
}
static async canvasToArrayBuffer(canvas, mime = 'image/png') {
const reader = new FileReader();
return new Promise(resolve => {
canvas.toBlob(blob => {
reader.addEventListener('loadend', () => {
resolve(reader.result);
});
reader.readAsArrayBuffer(blob);
}, mime);
});
}
}
export class FileUtils {

View File

@ -257,9 +257,11 @@ export class BetterDiscord {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
for (let [header, values] of Object.entries(details.responseHeaders)) {
if (!header.match(/^Content-Security-Policy(-Report-Only)?$/i)) continue;
details.responseHeaders[header] = values.map(value => {
const policy = new ContentSecurityPolicy(value);
for (const [key, value] of Object.entries(CSP)) {
if (!policy.get(key)) continue;
policy.add(key, value.join(' '));
}
return policy.toString();

View File

@ -81,6 +81,8 @@ gulp.task('node-sass-bindings', function () {
]);
});
gulp.task('build-release', gulp.parallel('release-package', 'client', 'core', 'sparkplug', 'core-modules', 'index', 'css-editor', gulp.series('dependencies', 'node-sass-bindings')));
gulp.task('release', gulp.series(function () {
return del(['release/**/*']);
}, gulp.parallel('release-package', 'client', 'core', 'sparkplug', 'core-modules', 'index', 'css-editor', gulp.series('dependencies', 'node-sass-bindings'))));
}, 'build-release'));

View File

@ -82,6 +82,7 @@
"build_node-sass": "node scripts/build-node-sass.js",
"build_release": "npm run release --prefix client && npm run build --prefix core && npm run release --prefix csseditor",
"package_release": "node scripts/package-release.js",
"release": "npm run lint && npm run build_release && gulp release && npm run package_release"
"release": "npm run lint && npm run build_release && gulp release && npm run package_release",
"update_release": "npm run build_release && gulp build-release"
}
}