mirror of
https://github.com/darkwire/darkwire.io.git
synced 2025-07-18 02:44:01 +00:00
This reverts commit 2a8d3281db851d15f9402d0fe69291255c6b250b.
This commit is contained in:
parent
5affceb47e
commit
18065f9652
20
readme.md
20
readme.md
@ -16,22 +16,6 @@ The Darkwire.io [web client](/client) is written in JavaScript with React JS and
|
||||
|
||||
### Development
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Copy `.env.dist` files in `server/` and `client/` directories without the `.dist`
|
||||
extensions and adapt them to your needs.
|
||||
|
||||
You need [Redis](https://redis.io/) in order to make the server works.
|
||||
A simple way to achieve this, if you have docker, is to execute the following
|
||||
command:
|
||||
|
||||
```
|
||||
docker run --name darkwire-redis --rm -p 6379:6379 -d redis redis-server --appendonly yes
|
||||
```
|
||||
|
||||
Alternatively, you can select the _memory_ `STORE_BACKEND` instead of _redis_
|
||||
in your server `.env` file to avoid Redis use.
|
||||
|
||||
#### Setup
|
||||
|
||||
Install dependencies
|
||||
@ -74,7 +58,7 @@ Here's an overview of a chat between Alice and Bob (also applies to group chats)
|
||||
1. Bob creates a room and immediately creates a public/private key pair (RSA-OAEP).
|
||||
2. Alice joins the room and also creates a public/private key pair. She is sent Bob's public key and she sends Bob her public key.
|
||||
3. When Bob goes to send a message, three things are created: a session key (AES-CBC), a signing key (HMAC SHA-256) and an initialization vector (used in the encryption process).
|
||||
4. Bob's message is encrypted with the session key and initialization vector, and a signature is created using the signing key.
|
||||
4. Bob's message is encrypted with the session key and initialization vector, and a signature is created using the signing key.
|
||||
5. The session key and signing key are encrypted with each recipient's public key (in this case only Alice, but in a group chat multiple).
|
||||
6. The encrypted message, initialization vector, signature, encrypted session key and encrypted signing key are sent to all recipients (in this case just Alice) as a package.
|
||||
7. Alice receives the package and decrypts the session key and signing key using her private key. She decrypts the message with the decrypted session key and vector, and verifies the signature with the decrypted signing key.
|
||||
@ -87,7 +71,7 @@ Darkwire does not provide any guarantee that the person you're communicating wit
|
||||
|
||||
## File Transfer
|
||||
|
||||
Darkwire encodes documents (up to 1MB) into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are.
|
||||
Darkwire encodes documents (up to 1MB) into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are.
|
||||
|
||||
1. When a file is "uploaded", the document is encoded on the client and the server recieves the encrypted base64 string.
|
||||
2. The server sends the encrypted base64 string to clients in the same chat room.
|
||||
|
@ -1,15 +1,8 @@
|
||||
# Abuse mail configuration
|
||||
MAILGUN_API_KEY=api-key
|
||||
MAILGUN_DOMAIN=darkwire.io
|
||||
ABUSE_TO_EMAIL_ADDRESS=abuse@darkwire.io
|
||||
ABUSE_FROM_EMAIL_ADDRESS=Darkwire <no-reply@darkwire.io>
|
||||
|
||||
REDIS_URL=redis://localhost:6379
|
||||
CLIENT_DIST_DIRECTORY='client/dist/path'
|
||||
|
||||
ROOM_HASH_SECRET='some-uuid'
|
||||
|
||||
SITE_URL=https://darkwire.io
|
||||
|
||||
# Store configuration
|
||||
STORE_BACKEND=redis
|
||||
STORE_HOST=redis://localhost:6379
|
||||
SITE_URL=https://darkwire.io
|
@ -1,25 +1,21 @@
|
||||
import getStore from './store';
|
||||
import { getRedis } from './index'
|
||||
|
||||
export async function pollForInactiveRooms() {
|
||||
const store = getStore();
|
||||
const redis = getRedis();
|
||||
|
||||
console.log('Checking for inactive rooms...');
|
||||
const rooms = (await store.getAll('rooms')) || {};
|
||||
const rooms = await redis.hgetallAsync('rooms') || {};
|
||||
console.log(`${Object.keys(rooms).length} rooms found`);
|
||||
|
||||
Object.keys(rooms).forEach(async (roomId) => {
|
||||
Object.keys(rooms).forEach(async roomId => {
|
||||
const room = JSON.parse(rooms[roomId]);
|
||||
const timeSinceUpdatedInSeconds = (Date.now() - room.updatedAt) / 1000;
|
||||
const timeSinceUpdatedInDays = Math.round(
|
||||
timeSinceUpdatedInSeconds / 60 / 60 / 24
|
||||
);
|
||||
const timeSinceUpdatedInDays = Math.round(timeSinceUpdatedInSeconds / 60 / 60 / 24);
|
||||
if (timeSinceUpdatedInDays > 7) {
|
||||
console.log(
|
||||
`Deleting roomId ${roomId} which hasn't been used in ${timeSinceUpdatedInDays} days`
|
||||
);
|
||||
await store.del('rooms', roomId);
|
||||
console.log(`Deleting roomId ${roomId} which hasn't been used in ${timeSinceUpdatedInDays} days`);
|
||||
await redis.hdelAsync('rooms', roomId);
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
setTimeout(pollForInactiveRooms, 1000 * 60 * 60 * 12); // every 12 hours
|
||||
setTimeout(pollForInactiveRooms, (1000 * 60 * 60 * 12)); // every 12 hours
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
require('dotenv').config();
|
||||
require('dotenv').config()
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import Koa from 'koa';
|
||||
@ -6,13 +6,22 @@ import Io from 'socket.io';
|
||||
import KoaBody from 'koa-body';
|
||||
import cors from 'kcors';
|
||||
import Router from 'koa-router';
|
||||
import bluebird from 'bluebird';
|
||||
import Redis from 'redis';
|
||||
import socketRedis from 'socket.io-redis';
|
||||
import Socket from './socket';
|
||||
import crypto from 'crypto';
|
||||
import crypto from 'crypto'
|
||||
import mailer from './utils/mailer';
|
||||
import koaStatic from 'koa-static';
|
||||
import koaSend from 'koa-send';
|
||||
import { pollForInactiveRooms } from './inactive_rooms';
|
||||
import getStore from './store';
|
||||
import {pollForInactiveRooms} from './inactive_rooms';
|
||||
|
||||
bluebird.promisifyAll(Redis.RedisClient.prototype);
|
||||
bluebird.promisifyAll(Redis.Multi.prototype);
|
||||
|
||||
const redis = Redis.createClient(process.env.REDIS_URL)
|
||||
|
||||
export const getRedis = () => redis
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
@ -26,16 +35,12 @@ const appName = process.env.HEROKU_APP_NAME;
|
||||
const isReviewApp = /-pr-/.test(appName);
|
||||
const siteURL = process.env.SITE_URL;
|
||||
|
||||
const store = getStore();
|
||||
|
||||
if ((siteURL || env === 'development') && !isReviewApp) {
|
||||
app.use(
|
||||
cors({
|
||||
origin: env === 'development' ? '*' : siteURL,
|
||||
allowMethods: ['GET', 'HEAD', 'POST'],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(cors({
|
||||
origin: env === 'development' ? '*' : siteURL,
|
||||
allowMethods: ['GET','HEAD','POST'],
|
||||
credentials: true,
|
||||
}));
|
||||
}
|
||||
|
||||
router.post('/abuse/:roomId', koaBody, async (ctx) => {
|
||||
@ -43,22 +48,19 @@ router.post('/abuse/:roomId', koaBody, async (ctx) => {
|
||||
|
||||
roomId = roomId.trim();
|
||||
|
||||
if (
|
||||
process.env.ABUSE_FROM_EMAIL_ADDRESS &&
|
||||
process.env.ABUSE_TO_EMAIL_ADDRESS
|
||||
) {
|
||||
const abuseForRoomExists = await store.get('abuse', roomId);
|
||||
if (process.env.ABUSE_FROM_EMAIL_ADDRESS && process.env.ABUSE_TO_EMAIL_ADDRESS) {
|
||||
const abuseForRoomExists = await redis.hgetAsync('abuse', roomId);
|
||||
if (!abuseForRoomExists) {
|
||||
mailer.send({
|
||||
from: process.env.ABUSE_FROM_EMAIL_ADDRESS,
|
||||
to: process.env.ABUSE_TO_EMAIL_ADDRESS,
|
||||
subject: 'Darkwire Abuse Notification',
|
||||
text: `Room ID: ${roomId}`,
|
||||
text: `Room ID: ${roomId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await store.inc('abuse', roomId);
|
||||
|
||||
await redis.hincrbyAsync('abuse', roomId, 1);
|
||||
|
||||
ctx.status = 200;
|
||||
});
|
||||
@ -66,9 +68,7 @@ router.post('/abuse/:roomId', koaBody, async (ctx) => {
|
||||
app.use(router.routes());
|
||||
|
||||
const apiHost = process.env.API_HOST;
|
||||
const cspDefaultSrc = `'self'${
|
||||
apiHost ? ` https://${apiHost} wss://${apiHost}` : ''
|
||||
}`;
|
||||
const cspDefaultSrc = `'self'${apiHost ? ` https://${apiHost} wss://${apiHost}` : ''}`
|
||||
|
||||
function setStaticFileHeaders(ctx) {
|
||||
ctx.set({
|
||||
@ -78,8 +78,7 @@ function setStaticFileHeaders(ctx) {
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Feature-Policy':
|
||||
"geolocation 'none'; vr 'none'; payment 'none'; microphone 'none'",
|
||||
'Feature-Policy': "geolocation 'none'; vr 'none'; payment 'none'; microphone 'none'",
|
||||
});
|
||||
}
|
||||
|
||||
@ -88,16 +87,16 @@ if (clientDistDirectory) {
|
||||
app.use(async (ctx, next) => {
|
||||
setStaticFileHeaders(ctx);
|
||||
await koaStatic(clientDistDirectory, {
|
||||
maxage: ctx.req.url === '/' ? 60 * 1000 : 365 * 24 * 60 * 60 * 1000, // one minute in ms for html doc, one year for css, js, etc
|
||||
maxage: ctx.req.url === '/' ? 60 * 1000 : 365 * 24 * 60 * 60 * 1000 // one minute in ms for html doc, one year for css, js, etc
|
||||
})(ctx, next);
|
||||
});
|
||||
|
||||
app.use(async (ctx) => {
|
||||
setStaticFileHeaders(ctx);
|
||||
await koaSend(ctx, 'index.html', { root: clientDistDirectory });
|
||||
});
|
||||
})
|
||||
} else {
|
||||
app.use(async (ctx) => {
|
||||
app.use(async ctx => {
|
||||
ctx.body = { ready: true };
|
||||
});
|
||||
}
|
||||
@ -107,52 +106,52 @@ const protocol = (process.env.PROTOCOL || 'http') === 'http' ? http : https;
|
||||
const server = protocol.createServer(app.callback());
|
||||
const io = Io(server, {
|
||||
pingInterval: 20000,
|
||||
pingTimeout: 5000,
|
||||
pingTimeout: 5000
|
||||
});
|
||||
|
||||
// Only use socket adapter if store has one
|
||||
if (store.hasSocketAdapter) {
|
||||
io.adapter(store.getSocketAdapter());
|
||||
}
|
||||
io.adapter(socketRedis(process.env.REDIS_URL));
|
||||
|
||||
const roomHashSecret = process.env.ROOM_HASH_SECRET;
|
||||
|
||||
const getRoomIdHash = (id) => {
|
||||
if (env === 'development') {
|
||||
return id;
|
||||
return id
|
||||
}
|
||||
|
||||
if (roomHashSecret) {
|
||||
return crypto.createHmac('sha256', roomHashSecret).update(id).digest('hex');
|
||||
return crypto
|
||||
.createHmac('sha256', roomHashSecret)
|
||||
.update(id)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
return crypto.createHash('sha256').update(id).digest('hex');
|
||||
};
|
||||
}
|
||||
|
||||
export const getIO = () => io;
|
||||
export const getIO = () => io
|
||||
|
||||
io.on('connection', async (socket) => {
|
||||
const roomId = socket.handshake.query.roomId;
|
||||
const roomId = socket.handshake.query.roomId
|
||||
|
||||
const roomIdHash = getRoomIdHash(roomId);
|
||||
const roomIdHash = getRoomIdHash(roomId)
|
||||
|
||||
let room = await store.get('rooms', roomIdHash);
|
||||
room = JSON.parse(room || '{}');
|
||||
let room = await redis.hgetAsync('rooms', roomIdHash)
|
||||
room = JSON.parse(room || '{}')
|
||||
|
||||
new Socket({
|
||||
roomIdOriginal: roomId,
|
||||
roomId: roomIdHash,
|
||||
socket,
|
||||
room,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
const init = async () => {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Darkwire is online at port ${PORT}`);
|
||||
});
|
||||
})
|
||||
|
||||
pollForInactiveRooms();
|
||||
};
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
init();
|
||||
|
@ -1,27 +1,27 @@
|
||||
import _ from 'lodash';
|
||||
import { getIO } from './index';
|
||||
import getStore from './store';
|
||||
import uuid from 'uuid/v4';
|
||||
import { getIO, getRedis } from './index'
|
||||
|
||||
export default class Socket {
|
||||
constructor(opts) {
|
||||
const { roomId, socket, room, roomIdOriginal } = opts;
|
||||
const { roomId, socket, room, roomIdOriginal } = opts
|
||||
|
||||
this._roomId = roomId;
|
||||
this._roomId = roomId
|
||||
this.socket = socket;
|
||||
this.roomIdOriginal = roomIdOriginal;
|
||||
this.room = room;
|
||||
if (room.isLocked) {
|
||||
this.sendRoomLocked();
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
this.init(opts);
|
||||
}
|
||||
this.init(opts)
|
||||
}
|
||||
|
||||
async init(opts) {
|
||||
const { roomId, socket, room } = opts;
|
||||
await this.joinRoom(roomId, socket);
|
||||
this.handleSocket(socket);
|
||||
const { roomId, socket, room } = opts
|
||||
await this.joinRoom(roomId, socket.id)
|
||||
this.handleSocket(socket)
|
||||
}
|
||||
|
||||
sendRoomLocked() {
|
||||
@ -32,41 +32,30 @@ export default class Socket {
|
||||
const json = {
|
||||
...room,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return getStore().set('rooms', this._roomId, JSON.stringify(json));
|
||||
return getRedis().hsetAsync('rooms', this._roomId, JSON.stringify(json))
|
||||
}
|
||||
|
||||
async destroyRoom() {
|
||||
return getStore().del('rooms', this._roomId);
|
||||
return getRedis().hdel('rooms', this._roomId)
|
||||
}
|
||||
|
||||
fetchRoom() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const res = await getStore().get('rooms', this._roomId);
|
||||
resolve(JSON.parse(res || '{}'));
|
||||
});
|
||||
const res = await getRedis().hgetAsync('rooms', this._roomId)
|
||||
resolve(JSON.parse(res || '{}'))
|
||||
})
|
||||
}
|
||||
|
||||
joinRoom(roomId, socket) {
|
||||
joinRoom(roomId, socketId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (getStore().hasSocketAdapter) {
|
||||
getIO()
|
||||
.of('/')
|
||||
.adapter.remoteJoin(socket.id, roomId, (err) => {
|
||||
if (err) {
|
||||
reject();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
socket.join(roomId, (err) => {
|
||||
if (err) {
|
||||
reject();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
getIO().of('/').adapter.remoteJoin(socketId, roomId, (err) => {
|
||||
if (err) {
|
||||
reject()
|
||||
}
|
||||
resolve()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -76,63 +65,56 @@ export default class Socket {
|
||||
});
|
||||
|
||||
socket.on('USER_ENTER', async (payload) => {
|
||||
let room = await this.fetchRoom();
|
||||
let room = await this.fetchRoom()
|
||||
if (_.isEmpty(room)) {
|
||||
room = {
|
||||
id: this._roomId,
|
||||
users: [],
|
||||
isLocked: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newRoom = {
|
||||
...room,
|
||||
users: [
|
||||
...(room.users || []),
|
||||
{
|
||||
socketId: socket.id,
|
||||
publicKey: payload.publicKey,
|
||||
isOwner: (room.users || []).length === 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
await this.saveRoom(newRoom);
|
||||
users: [...(room.users || []), {
|
||||
socketId: socket.id,
|
||||
publicKey: payload.publicKey,
|
||||
isOwner: (room.users || []).length === 0,
|
||||
}]
|
||||
}
|
||||
await this.saveRoom(newRoom)
|
||||
|
||||
getIO()
|
||||
.to(this._roomId)
|
||||
.emit('USER_ENTER', {
|
||||
...newRoom,
|
||||
id: this.roomIdOriginal,
|
||||
});
|
||||
});
|
||||
getIO().to(this._roomId).emit('USER_ENTER', {
|
||||
...newRoom,
|
||||
id: this.roomIdOriginal
|
||||
});
|
||||
})
|
||||
|
||||
socket.on('TOGGLE_LOCK_ROOM', async (data, callback) => {
|
||||
const room = await this.fetchRoom();
|
||||
const user = (room.users || []).find(
|
||||
(u) => u.socketId === socket.id && u.isOwner
|
||||
);
|
||||
const room = await this.fetchRoom()
|
||||
const user = (room.users || []).find(u => u.socketId === socket.id && u.isOwner)
|
||||
|
||||
if (!user) {
|
||||
callback({
|
||||
isLocked: room.isLocked,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await this.saveRoom({
|
||||
...room,
|
||||
isLocked: !room.isLocked,
|
||||
});
|
||||
})
|
||||
|
||||
socket.to(this._roomId).emit('TOGGLE_LOCK_ROOM', {
|
||||
locked: !room.isLocked,
|
||||
publicKey: user && user.publicKey,
|
||||
publicKey: user && user.publicKey
|
||||
});
|
||||
|
||||
callback({
|
||||
isLocked: !room.isLocked,
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => this.handleDisconnect(socket));
|
||||
@ -141,26 +123,25 @@ export default class Socket {
|
||||
}
|
||||
|
||||
async handleDisconnect(socket) {
|
||||
let room = await this.fetchRoom();
|
||||
let room = await this.fetchRoom()
|
||||
|
||||
const newRoom = {
|
||||
...room,
|
||||
users: (room.users || [])
|
||||
.filter((u) => u.socketId !== socket.id)
|
||||
.map((u, index) => ({
|
||||
...u,
|
||||
isOwner: index === 0,
|
||||
})),
|
||||
};
|
||||
users: (room.users || []).filter(u => u.socketId !== socket.id).map((u, index) => ({
|
||||
...u,
|
||||
isOwner: index === 0,
|
||||
}))
|
||||
}
|
||||
|
||||
await this.saveRoom(newRoom);
|
||||
await this.saveRoom(newRoom)
|
||||
|
||||
getIO().to(this._roomId).emit('USER_EXIT', newRoom.users);
|
||||
|
||||
if (newRoom.users && newRoom.users.length === 0) {
|
||||
await this.destroyRoom();
|
||||
await this.destroyRoom()
|
||||
}
|
||||
|
||||
socket.disconnect(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Memory store more for testing purpose than production use.
|
||||
*/
|
||||
export class MemoryStore {
|
||||
constructor() {
|
||||
this.store = {};
|
||||
this.hasSocketAdapter = false;
|
||||
}
|
||||
|
||||
async get(key, field) {
|
||||
if (this.store[key] === undefined || this.store[key][field] === undefined) {
|
||||
return null;
|
||||
}
|
||||
return this.store[key][field];
|
||||
}
|
||||
|
||||
async getAll(key) {
|
||||
if (this.store[key] === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.store[key];
|
||||
}
|
||||
|
||||
async set(key, field, value) {
|
||||
if (this.store[key] === undefined) {
|
||||
this.store[key] = {};
|
||||
}
|
||||
this.store[key][field] = value;
|
||||
return 1;
|
||||
}
|
||||
|
||||
async del(key, field) {
|
||||
if (this.store[key] === undefined || this.store[key][field] === undefined) {
|
||||
return 0;
|
||||
}
|
||||
delete this.store[key][field];
|
||||
return 1;
|
||||
}
|
||||
|
||||
async inc(key, field, inc = 1) {
|
||||
this.store[key][field] += inc;
|
||||
return this.store[key][field];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default MemoryStore
|
@ -1,42 +0,0 @@
|
||||
import Redis from 'redis';
|
||||
import bluebird from 'bluebird';
|
||||
import socketRedis from 'socket.io-redis';
|
||||
|
||||
/**
|
||||
* Redis store.
|
||||
*/
|
||||
export class RedisStore {
|
||||
constructor(redisUrl) {
|
||||
bluebird.promisifyAll(Redis.RedisClient.prototype);
|
||||
bluebird.promisifyAll(Redis.Multi.prototype);
|
||||
this.redisUrl = redisUrl;
|
||||
this.redis = Redis.createClient(redisUrl);
|
||||
this.hasSocketAdapter = true;
|
||||
}
|
||||
|
||||
get(key, field) {
|
||||
return this.redis.hgetAsync(key, field);
|
||||
}
|
||||
|
||||
getAll(key) {
|
||||
return this.redis.hgetallAsync(key);
|
||||
}
|
||||
|
||||
set(key, field, value) {
|
||||
return this.redis.hsetAsync(key, field, value);
|
||||
}
|
||||
|
||||
del(key, field) {
|
||||
return this.hdelAsync(key, field);
|
||||
}
|
||||
|
||||
inc(key, field, inc = 1) {
|
||||
return this.redis.incrbyAsync(key, field, inc);
|
||||
}
|
||||
|
||||
getSocketAdapter() {
|
||||
return socketRedis(this.redisUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export default RedisStore
|
@ -1,19 +0,0 @@
|
||||
import MemoryStore from './Memory';
|
||||
import RedisStore from './Redis';
|
||||
|
||||
const storeBackend = process.env.STORE_BACKEND || 'redis';
|
||||
|
||||
let store;
|
||||
switch (storeBackend) {
|
||||
case 'memory':
|
||||
store = new MemoryStore();
|
||||
break;
|
||||
case 'redis':
|
||||
default:
|
||||
store = new RedisStore(process.env.STORE_HOST);
|
||||
break;
|
||||
}
|
||||
|
||||
const getStore = () => store;
|
||||
|
||||
export default getStore;
|
Loading…
x
Reference in New Issue
Block a user