Allow to launch server without Redis (#119)

* Add nvmrc file for nvm use

* Add help to run redis store with docker

* Add Redis and memory store

* Rename dist files

* Allow to launch server without Redis

* Slit stores in their own files

* Update readme.md

Co-authored-by: Alan Friedman <d.alan.friedman@gmail.com>

* Mimic Redis API

* Move store logic in is own file

Co-authored-by: Alan Friedman <d.alan.friedman@gmail.com>
This commit is contained in:
Jérémie Pardou-Piquemal 2020-05-05 14:56:14 +02:00 committed by GitHub
parent 4c5207d205
commit 2a8d3281db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 270 additions and 113 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/*

View File

@ -16,6 +16,22 @@ The Darkwire.io [web client](/client) is written in JavaScript with React JS and
### Development ### 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 #### Setup
Install dependencies Install dependencies

View File

@ -1,8 +1,15 @@
# Abuse mail configuration
MAILGUN_API_KEY=api-key MAILGUN_API_KEY=api-key
MAILGUN_DOMAIN=darkwire.io MAILGUN_DOMAIN=darkwire.io
ABUSE_TO_EMAIL_ADDRESS=abuse@darkwire.io ABUSE_TO_EMAIL_ADDRESS=abuse@darkwire.io
ABUSE_FROM_EMAIL_ADDRESS=Darkwire <no-reply@darkwire.io> ABUSE_FROM_EMAIL_ADDRESS=Darkwire <no-reply@darkwire.io>
REDIS_URL=redis://localhost:6379
CLIENT_DIST_DIRECTORY='client/dist/path' CLIENT_DIST_DIRECTORY='client/dist/path'
ROOM_HASH_SECRET='some-uuid' ROOM_HASH_SECRET='some-uuid'
SITE_URL=https://darkwire.io SITE_URL=https://darkwire.io
# Store configuration
STORE_BACKEND=redis
STORE_HOST=redis://localhost:6379

View File

@ -1,21 +1,25 @@
import { getRedis } from './index' import getStore from './store';
export async function pollForInactiveRooms() { export async function pollForInactiveRooms() {
const redis = getRedis(); const store = getStore();
console.log('Checking for inactive rooms...'); console.log('Checking for inactive rooms...');
const rooms = await redis.hgetallAsync('rooms') || {}; const rooms = (await store.getAll('rooms')) || {};
console.log(`${Object.keys(rooms).length} rooms found`); 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 room = JSON.parse(rooms[roomId]);
const timeSinceUpdatedInSeconds = (Date.now() - room.updatedAt) / 1000; 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) { if (timeSinceUpdatedInDays > 7) {
console.log(`Deleting roomId ${roomId} which hasn't been used in ${timeSinceUpdatedInDays} days`); console.log(
await redis.hdelAsync('rooms', roomId); `Deleting roomId ${roomId} which hasn't been used in ${timeSinceUpdatedInDays} days`
);
await store.del('rooms', roomId);
} }
}) });
setTimeout(pollForInactiveRooms, (1000 * 60 * 60 * 12)); // every 12 hours setTimeout(pollForInactiveRooms, 1000 * 60 * 60 * 12); // every 12 hours
} }

View File

@ -1,4 +1,4 @@
require('dotenv').config() require('dotenv').config();
import http from 'http'; import http from 'http';
import https from 'https'; import https from 'https';
import Koa from 'koa'; import Koa from 'koa';
@ -6,22 +6,13 @@ import Io from 'socket.io';
import KoaBody from 'koa-body'; import KoaBody from 'koa-body';
import cors from 'kcors'; import cors from 'kcors';
import Router from 'koa-router'; import Router from 'koa-router';
import bluebird from 'bluebird';
import Redis from 'redis';
import socketRedis from 'socket.io-redis';
import Socket from './socket'; import Socket from './socket';
import crypto from 'crypto' import crypto from 'crypto';
import mailer from './utils/mailer'; import mailer from './utils/mailer';
import koaStatic from 'koa-static'; import koaStatic from 'koa-static';
import koaSend from 'koa-send'; import koaSend from 'koa-send';
import { pollForInactiveRooms } from './inactive_rooms'; import { pollForInactiveRooms } from './inactive_rooms';
import getStore from './store';
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'; const env = process.env.NODE_ENV || 'development';
@ -35,12 +26,16 @@ const appName = process.env.HEROKU_APP_NAME;
const isReviewApp = /-pr-/.test(appName); const isReviewApp = /-pr-/.test(appName);
const siteURL = process.env.SITE_URL; const siteURL = process.env.SITE_URL;
const store = getStore();
if ((siteURL || env === 'development') && !isReviewApp) { if ((siteURL || env === 'development') && !isReviewApp) {
app.use(cors({ app.use(
cors({
origin: env === 'development' ? '*' : siteURL, origin: env === 'development' ? '*' : siteURL,
allowMethods: ['GET', 'HEAD', 'POST'], allowMethods: ['GET', 'HEAD', 'POST'],
credentials: true, credentials: true,
})); })
);
} }
router.post('/abuse/:roomId', koaBody, async (ctx) => { router.post('/abuse/:roomId', koaBody, async (ctx) => {
@ -48,19 +43,22 @@ router.post('/abuse/:roomId', koaBody, async (ctx) => {
roomId = roomId.trim(); roomId = roomId.trim();
if (process.env.ABUSE_FROM_EMAIL_ADDRESS && process.env.ABUSE_TO_EMAIL_ADDRESS) { if (
const abuseForRoomExists = await redis.hgetAsync('abuse', roomId); process.env.ABUSE_FROM_EMAIL_ADDRESS &&
process.env.ABUSE_TO_EMAIL_ADDRESS
) {
const abuseForRoomExists = await store.get('abuse', roomId);
if (!abuseForRoomExists) { if (!abuseForRoomExists) {
mailer.send({ mailer.send({
from: process.env.ABUSE_FROM_EMAIL_ADDRESS, from: process.env.ABUSE_FROM_EMAIL_ADDRESS,
to: process.env.ABUSE_TO_EMAIL_ADDRESS, to: process.env.ABUSE_TO_EMAIL_ADDRESS,
subject: 'Darkwire Abuse Notification', subject: 'Darkwire Abuse Notification',
text: `Room ID: ${roomId}` text: `Room ID: ${roomId}`,
}); });
} }
} }
await redis.hincrbyAsync('abuse', roomId, 1); await store.inc('abuse', roomId);
ctx.status = 200; ctx.status = 200;
}); });
@ -68,7 +66,9 @@ router.post('/abuse/:roomId', koaBody, async (ctx) => {
app.use(router.routes()); app.use(router.routes());
const apiHost = process.env.API_HOST; 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) { function setStaticFileHeaders(ctx) {
ctx.set({ ctx.set({
@ -78,7 +78,8 @@ function setStaticFileHeaders(ctx) {
'X-XSS-Protection': '1; mode=block', 'X-XSS-Protection': '1; mode=block',
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer', 'Referrer-Policy': 'no-referrer',
'Feature-Policy': "geolocation 'none'; vr 'none'; payment 'none'; microphone 'none'", 'Feature-Policy':
"geolocation 'none'; vr 'none'; payment 'none'; microphone 'none'",
}); });
} }
@ -87,16 +88,16 @@ if (clientDistDirectory) {
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
setStaticFileHeaders(ctx); setStaticFileHeaders(ctx);
await koaStatic(clientDistDirectory, { 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); })(ctx, next);
}); });
app.use(async (ctx) => { app.use(async (ctx) => {
setStaticFileHeaders(ctx); setStaticFileHeaders(ctx);
await koaSend(ctx, 'index.html', { root: clientDistDirectory }); await koaSend(ctx, 'index.html', { root: clientDistDirectory });
}) });
} else { } else {
app.use(async ctx => { app.use(async (ctx) => {
ctx.body = { ready: true }; ctx.body = { ready: true };
}); });
} }
@ -106,52 +107,52 @@ const protocol = (process.env.PROTOCOL || 'http') === 'http' ? http : https;
const server = protocol.createServer(app.callback()); const server = protocol.createServer(app.callback());
const io = Io(server, { const io = Io(server, {
pingInterval: 20000, pingInterval: 20000,
pingTimeout: 5000 pingTimeout: 5000,
}); });
io.adapter(socketRedis(process.env.REDIS_URL));
// Only use socket adapter if store has one
if (store.hasSocketAdapter) {
io.adapter(store.getSocketAdapter());
}
const roomHashSecret = process.env.ROOM_HASH_SECRET; const roomHashSecret = process.env.ROOM_HASH_SECRET;
const getRoomIdHash = (id) => { const getRoomIdHash = (id) => {
if (env === 'development') { if (env === 'development') {
return id return id;
} }
if (roomHashSecret) { if (roomHashSecret) {
return crypto return crypto.createHmac('sha256', roomHashSecret).update(id).digest('hex');
.createHmac('sha256', roomHashSecret)
.update(id)
.digest('hex')
} }
return crypto.createHash('sha256').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) => { 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 redis.hgetAsync('rooms', roomIdHash) let room = await store.get('rooms', roomIdHash);
room = JSON.parse(room || '{}') room = JSON.parse(room || '{}');
new Socket({ new Socket({
roomIdOriginal: roomId, roomIdOriginal: roomId,
roomId: roomIdHash, roomId: roomIdHash,
socket, socket,
room, room,
}) });
}) });
const init = async () => { const init = async () => {
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Darkwire is online at port ${PORT}`); console.log(`Darkwire is online at port ${PORT}`);
}) });
pollForInactiveRooms(); pollForInactiveRooms();
} };
init()
init();

View File

@ -1,27 +1,27 @@
import _ from 'lodash'; import _ from 'lodash';
import uuid from 'uuid/v4'; import { getIO } from './index';
import { getIO, getRedis } from './index' import getStore from './store';
export default class Socket { export default class Socket {
constructor(opts) { constructor(opts) {
const { roomId, socket, room, roomIdOriginal } = opts const { roomId, socket, room, roomIdOriginal } = opts;
this._roomId = roomId this._roomId = roomId;
this.socket = socket; this.socket = socket;
this.roomIdOriginal = roomIdOriginal; this.roomIdOriginal = roomIdOriginal;
this.room = room; this.room = room;
if (room.isLocked) { if (room.isLocked) {
this.sendRoomLocked(); this.sendRoomLocked();
return return;
} }
this.init(opts) this.init(opts);
} }
async init(opts) { async init(opts) {
const { roomId, socket, room } = opts const { roomId, socket, room } = opts;
await this.joinRoom(roomId, socket.id) await this.joinRoom(roomId, socket);
this.handleSocket(socket) this.handleSocket(socket);
} }
sendRoomLocked() { sendRoomLocked() {
@ -32,30 +32,41 @@ export default class Socket {
const json = { const json = {
...room, ...room,
updatedAt: Date.now(), updatedAt: Date.now(),
} };
return getRedis().hsetAsync('rooms', this._roomId, JSON.stringify(json)) return getStore().set('rooms', this._roomId, JSON.stringify(json));
} }
async destroyRoom() { async destroyRoom() {
return getRedis().hdel('rooms', this._roomId) return getStore().del('rooms', this._roomId);
} }
fetchRoom() { fetchRoom() {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const res = await getRedis().hgetAsync('rooms', this._roomId) const res = await getStore().get('rooms', this._roomId);
resolve(JSON.parse(res || '{}')) resolve(JSON.parse(res || '{}'));
}) });
} }
joinRoom(roomId, socketId) { joinRoom(roomId, socket) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getIO().of('/').adapter.remoteJoin(socketId, roomId, (err) => { if (getStore().hasSocketAdapter) {
getIO()
.of('/')
.adapter.remoteJoin(socket.id, roomId, (err) => {
if (err) { if (err) {
reject() reject();
} }
resolve() resolve();
}); });
} else {
socket.join(roomId, (err) => {
if (err) {
reject();
}
resolve();
});
}
}); });
} }
@ -65,56 +76,63 @@ export default class Socket {
}); });
socket.on('USER_ENTER', async (payload) => { socket.on('USER_ENTER', async (payload) => {
let room = await this.fetchRoom() let room = await this.fetchRoom();
if (_.isEmpty(room)) { if (_.isEmpty(room)) {
room = { room = {
id: this._roomId, id: this._roomId,
users: [], users: [],
isLocked: false, isLocked: false,
createdAt: Date.now(), createdAt: Date.now(),
} };
} }
const newRoom = { const newRoom = {
...room, ...room,
users: [...(room.users || []), { users: [
...(room.users || []),
{
socketId: socket.id, socketId: socket.id,
publicKey: payload.publicKey, publicKey: payload.publicKey,
isOwner: (room.users || []).length === 0, isOwner: (room.users || []).length === 0,
}] },
} ],
await this.saveRoom(newRoom) };
await this.saveRoom(newRoom);
getIO().to(this._roomId).emit('USER_ENTER', { getIO()
.to(this._roomId)
.emit('USER_ENTER', {
...newRoom, ...newRoom,
id: this.roomIdOriginal id: this.roomIdOriginal,
});
}); });
})
socket.on('TOGGLE_LOCK_ROOM', async (data, callback) => { socket.on('TOGGLE_LOCK_ROOM', async (data, callback) => {
const room = await this.fetchRoom() const room = await this.fetchRoom();
const user = (room.users || []).find(u => u.socketId === socket.id && u.isOwner) const user = (room.users || []).find(
(u) => u.socketId === socket.id && u.isOwner
);
if (!user) { if (!user) {
callback({ callback({
isLocked: room.isLocked, isLocked: room.isLocked,
}) });
return return;
} }
await this.saveRoom({ await this.saveRoom({
...room, ...room,
isLocked: !room.isLocked, isLocked: !room.isLocked,
}) });
socket.to(this._roomId).emit('TOGGLE_LOCK_ROOM', { socket.to(this._roomId).emit('TOGGLE_LOCK_ROOM', {
locked: !room.isLocked, locked: !room.isLocked,
publicKey: user && user.publicKey publicKey: user && user.publicKey,
}); });
callback({ callback({
isLocked: !room.isLocked, isLocked: !room.isLocked,
}) });
}); });
socket.on('disconnect', () => this.handleDisconnect(socket)); socket.on('disconnect', () => this.handleDisconnect(socket));
@ -123,25 +141,26 @@ export default class Socket {
} }
async handleDisconnect(socket) { async handleDisconnect(socket) {
let room = await this.fetchRoom() let room = await this.fetchRoom();
const newRoom = { const newRoom = {
...room, ...room,
users: (room.users || []).filter(u => u.socketId !== socket.id).map((u, index) => ({ users: (room.users || [])
.filter((u) => u.socketId !== socket.id)
.map((u, index) => ({
...u, ...u,
isOwner: index === 0, isOwner: index === 0,
})) })),
} };
await this.saveRoom(newRoom) await this.saveRoom(newRoom);
getIO().to(this._roomId).emit('USER_EXIT', newRoom.users); getIO().to(this._roomId).emit('USER_EXIT', newRoom.users);
if (newRoom.users && newRoom.users.length === 0) { if (newRoom.users && newRoom.users.length === 0) {
await this.destroyRoom() await this.destroyRoom();
} }
socket.disconnect(true); socket.disconnect(true);
} }
} }

View File

@ -0,0 +1,48 @@
/**
* 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

42
server/src/store/Redis.js Normal file
View File

@ -0,0 +1,42 @@
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

19
server/src/store/index.js Normal file
View File

@ -0,0 +1,19 @@
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;