From de1729d56b5bcd10c14a7d760adbf22acc17f9e9 Mon Sep 17 00:00:00 2001 From: HF Date: Sat, 10 Sep 2022 00:35:28 +0200 Subject: [PATCH] Horicontal Scaling --- src/components/Admintools.jsx | 10 +- src/components/Captcha.jsx | 3 +- src/components/LanguageSelect.jsx | 9 +- src/components/ModCanvastools.jsx | 13 +- src/components/ModIIDtools.jsx | 3 +- src/components/ModWatchtools.jsx | 3 +- src/core/ChatProvider.js | 5 +- src/core/{mail.js => MailProvider.js} | 184 ++++++------- src/core/PixelCache.js | 2 +- src/core/Tile.js | 17 +- src/core/Void.js | 2 +- src/core/config.js | 6 +- src/core/ranking.js | 24 +- src/core/session.js | 42 +-- src/core/tileserver.js | 9 +- src/data/redis/RedisCanvas.js | 19 +- src/data/redis/client.js | 19 +- src/data/redis/mailCodes.js | 54 ++++ src/routes/api/auth/change_mail.js | 2 +- src/routes/api/auth/change_name.js | 2 +- src/routes/api/auth/delete_account.js | 5 +- src/routes/api/auth/register.js | 2 +- src/routes/api/auth/resend_verify.js | 4 +- src/routes/api/auth/restore_password.js | 2 +- src/routes/api/auth/verify.js | 43 +-- src/routes/api/block.js | 2 +- src/routes/api/blockdm.js | 2 +- src/routes/api/chathistory.js | 14 - src/routes/api/index.js | 12 +- src/routes/api/leavechan.js | 2 +- src/routes/api/me.js | 4 - src/routes/api/shards.js | 20 ++ src/routes/chunks.js | 9 +- src/routes/index.js | 91 ++++--- src/routes/reset_password.js | 2 +- src/routes/tiles.js | 3 + src/server.js | 32 ++- src/socket/APISocketServer.js | 3 +- src/socket/MessageBroker.js | 254 ++++++++++++++++++ src/socket/{SocketEvents.js => SockEvents.js} | 53 +++- src/socket/SocketClient.js | 8 +- src/socket/SocketServer.js | 29 +- src/socket/packets/ChunkUpdate.js | 30 +++ src/socket/packets/PixelUpdateMB.js | 44 +++ src/socket/packets/PixelUpdateServer.js | 4 +- src/socket/socketEvents.js | 10 + src/ssr/Main.jsx | 10 +- src/ssr/PopUp.jsx | 10 +- src/store/actions/fetch.js | 62 ++++- src/store/actions/thunks.js | 16 +- src/ui/ChunkLoader2D.js | 6 +- src/ui/ChunkLoader3D.js | 7 +- src/utils/corsMiddleware.js | 39 +++ src/utils/ip.js | 11 +- webpack.config.server.js | 4 +- 55 files changed, 936 insertions(+), 341 deletions(-) rename src/core/{mail.js => MailProvider.js} (59%) create mode 100644 src/data/redis/mailCodes.js create mode 100644 src/routes/api/shards.js create mode 100644 src/socket/MessageBroker.js rename src/socket/{SocketEvents.js => SockEvents.js} (76%) create mode 100644 src/socket/packets/ChunkUpdate.js create mode 100644 src/socket/packets/PixelUpdateMB.js create mode 100644 src/socket/socketEvents.js create mode 100644 src/utils/corsMiddleware.js diff --git a/src/components/Admintools.jsx b/src/components/Admintools.jsx index ef1a877..b0ac8ef 100644 --- a/src/components/Admintools.jsx +++ b/src/components/Admintools.jsx @@ -5,6 +5,8 @@ import React, { useState, useEffect } from 'react'; import { t } from 'ttag'; +import { shardOrigin } from '../store/actions/fetch'; + async function submitIPAction( action, vallist, @@ -13,7 +15,7 @@ async function submitIPAction( const data = new FormData(); data.append('ipaction', action); data.append('ip', vallist); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -26,7 +28,7 @@ async function getModList( ) { const data = new FormData(); data.append('modlist', true); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -44,7 +46,7 @@ async function submitRemMod( ) { const data = new FormData(); data.append('remmod', userId); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -58,7 +60,7 @@ async function submitMakeMod( ) { const data = new FormData(); data.append('makemod', userName); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, diff --git a/src/components/Captcha.jsx b/src/components/Captcha.jsx index 024aa3a..54d4fea 100644 --- a/src/components/Captcha.jsx +++ b/src/components/Captcha.jsx @@ -10,9 +10,10 @@ import React, { useState, useEffect } from 'react'; import { t } from 'ttag'; import { IoReloadCircleSharp } from 'react-icons/io5'; +import { shardOrigin } from '../store/actions/fetch'; async function getUrlAndId() { - const url = './captcha.svg'; + const url = `${shardOrigin}/captcha.svg`; const resp = await fetch(url, { cache: 'no-cache', }); diff --git a/src/components/LanguageSelect.jsx b/src/components/LanguageSelect.jsx index dfbbf7c..dd55b24 100644 --- a/src/components/LanguageSelect.jsx +++ b/src/components/LanguageSelect.jsx @@ -66,7 +66,14 @@ function LanguageSelect() { /* set with selected language */ const d = new Date(); d.setTime(d.getTime() + 24 * MONTH); - document.cookie = `lang=${langSel};expires=${d.toUTCString()};path=/`; + let { host } = window.location; + if (host.lastIndexOf('.') !== host.indexOf('.')) { + host = host.slice(host.indexOf('.')); + } else { + host = `.${host}`; + } + // eslint-disable-next-line max-len + document.cookie = `lang=${langSel};expires=${d.toUTCString()};path=/;domain=${host}`; window.location.reload(); }} > diff --git a/src/components/ModCanvastools.jsx b/src/components/ModCanvastools.jsx index 79ef80a..2fba559 100644 --- a/src/components/ModCanvastools.jsx +++ b/src/components/ModCanvastools.jsx @@ -8,6 +8,7 @@ import { t } from 'ttag'; import useInterval from './hooks/interval'; import { getToday, dateToString } from '../core/utils'; +import { shardOrigin } from '../store/actions/fetch'; const keptState = { coords: '', @@ -33,7 +34,7 @@ async function submitImageAction( data.append('image', file); data.append('canvasid', canvas); data.append('coords', coords); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -53,7 +54,7 @@ async function submitProtAction( data.append('canvasid', canvas); data.append('ulcoor', tlcoords); data.append('brcoor', brcoords); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -74,7 +75,7 @@ async function submitRollback( data.append('canvasid', canvas); data.append('ulcoor', tlcoords); data.append('brcoor', brcoords); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -94,7 +95,7 @@ async function submitCanvasCleaner( data.append('canvasid', canvas); data.append('ulcoor', tlcoords); data.append('brcoor', brcoords); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -107,7 +108,7 @@ async function getCleanerStats( ) { const data = new FormData(); data.append('cleanerstat', true); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, @@ -125,7 +126,7 @@ async function getCleanerCancel( ) { const data = new FormData(); data.append('cleanercancel', true); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, diff --git a/src/components/ModIIDtools.jsx b/src/components/ModIIDtools.jsx index e298c70..73adeba 100644 --- a/src/components/ModIIDtools.jsx +++ b/src/components/ModIIDtools.jsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { t } from 'ttag'; import { parseInterval } from '../core/utils'; +import { shardOrigin } from '../store/actions/fetch'; async function submitIIDAction( action, @@ -31,7 +32,7 @@ async function submitIIDAction( data.append('reason', reason); data.append('time', time); data.append('iid', iid); - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, diff --git a/src/components/ModWatchtools.jsx b/src/components/ModWatchtools.jsx index dae14b8..4c2ec37 100644 --- a/src/components/ModWatchtools.jsx +++ b/src/components/ModWatchtools.jsx @@ -9,6 +9,7 @@ import { t } from 'ttag'; import copyTextToClipboard from '../utils/clipboard'; import { parseInterval } from '../core/utils'; +import { shardOrigin } from '../store/actions/fetch'; const keepState = { tlcoords: '', @@ -54,7 +55,7 @@ async function submitWatchAction( data.append('time', time); data.append('iid', iid); try { - const resp = await fetch('/api/modtools', { + const resp = await fetch(`${shardOrigin}/api/modtools`, { credentials: 'include', method: 'POST', body: data, diff --git a/src/core/ChatProvider.js b/src/core/ChatProvider.js index 913a3f1..4ba0592 100644 --- a/src/core/ChatProvider.js +++ b/src/core/ChatProvider.js @@ -10,7 +10,7 @@ import { } from '../data/sql'; import { findIdByNameOrId } from '../data/sql/RegUser'; import ChatMessageBuffer from './ChatMessageBuffer'; -import socketEvents from '../socket/SocketEvents'; +import socketEvents from '../socket/socketEvents'; import checkIPAllowed from './isAllowed'; import { DailyCron } from '../utils/cron'; import { escapeMd } from './utils'; @@ -93,6 +93,9 @@ export class ChatProvider { } async clearOldMessages() { + if (!socketEvents.amIImportant()) { + return; + } const ids = Object.keys(this.defaultChannels); for (let i = 0; i < ids.length; i += 1) { const cid = ids[i]; diff --git a/src/core/mail.js b/src/core/MailProvider.js similarity index 59% rename from src/core/mail.js rename to src/core/MailProvider.js index 26e3333..d9095fd 100644 --- a/src/core/mail.js +++ b/src/core/MailProvider.js @@ -4,20 +4,17 @@ /* eslint-disable max-len */ -import { randomUUID } from 'crypto'; import nodemailer from 'nodemailer'; import logger from './logger'; -import { HOUR, MINUTE } from './constants'; -import { DailyCron, HourlyCron } from '../utils/cron'; import { getTTag } from './ttag'; +import { codeExists, checkCode, setCode } from '../data/redis/mailCodes'; +import socketEvents from '../socket/socketEvents'; import { USE_MAILER, MAIL_ADDRESS } from './config'; import { RegUser } from '../data/sql'; - -// TODO make code expire -class MailProvider { +export class MailProvider { constructor() { this.enabled = !!USE_MAILER; if (this.enabled) { @@ -26,13 +23,23 @@ class MailProvider { newline: 'unix', path: '/usr/sbin/sendmail', }); - - this.clearCodes = this.clearCodes.bind(this); - - this.verifyCodes = {}; - HourlyCron.hook(this.clearCodes); - DailyCron.hook(MailProvider.cleanUsers); } + + /* + * mail requests make it through SocketEvents when sharding + */ + socketEvents.on('mail', (type, args) => { + switch (type) { + case 'verify': + this.postVerifyMail(...args); + break; + case 'pwreset': + this.postPasswdResetMail(...args); + break; + default: + // nothing + } + }); } sendMail(to, subject, html) { @@ -52,29 +59,10 @@ class MailProvider { }); } - sendVerifyMail(to, name, host, lang) { - if (!this.enabled) { - return null; - } - + postVerifyMail(to, name, host, lang, code) { const { t } = getTTag(lang); - - const pastMail = this.verifyCodes[to]; - if (pastMail) { - const minLeft = Math.floor( - pastMail.timestamp / MINUTE + 2 - Date.now() / MINUTE, - ); - if (minLeft > 0) { - logger.info( - `Verify mail for ${to} - already sent, ${minLeft} minutes left`, - ); - return t`We already sent you a verification mail, you can request another one in ${minLeft} minutes.`; - } - } - logger.info(`Sending verification mail to ${to} / ${name}`); - const code = this.setCode(to); - const verifyUrl = `${host}/api/auth/verify?token=${code}`; + const verifyUrl = `${host}/api/auth/verify?token=${code}&email=${encodeURIComponent(to)}`; const subject = t`Welcome ${name} to PixelPlanet, plese verify your mail`; const html = `${t`Hello ${name}`},
${t`welcome to our little community of pixelplacers, to use your account, you have to verify your mail. You can do that here: `} ${t`Click to Verify`}. ${t`Or by copying following url:`}
${verifyUrl}\n
@@ -82,27 +70,60 @@ class MailProvider { ${t`Thanks`}

`; this.sendMail(to, subject, html); + } + + async sendVerifyMail(to, name, host, lang) { + if (!this.enabled && !socketEvents.isCluster) { + return null; + } + const { t } = getTTag(lang); + + const pastCodeAge = await codeExists(to); + if (pastCodeAge && pastCodeAge < 180) { + const minLeft = Math.ceil((180 - pastCodeAge) / 60); + logger.info( + `Verify mail for ${to} - already sent, ${minLeft} minutes left`, + ); + return t`We already sent you a verification mail, you can request another one in ${minLeft} minutes.`; + } + + const code = setCode(to); + if (this.enabled) { + this.postVerifyMail(to, name, host, lang, code); + } else { + socketEvents.sendMail('verify', [to, name, host, lang, code]); + } return null; } + postPasswdResetMail(to, ip, host, lang, code) { + const { t } = getTTag(lang); + logger.info(`Sending Password reset mail to ${to}`); + const restoreUrl = `${host}/reset_password?token=${code}`; + const subject = t`You forgot your password for PixelPlanet? Get a new one here`; + const html = `${t`Hello`},
+ ${t`You requested to get a new password. You can change your password within the next 30min here: `} ${t`Reset Password`}. ${t`Or by copying following url:`}
${restoreUrl}\n
+ ${t`If you did not request this mail, please just ignore it (the ip that requested this mail was ${ip}).`}
+ ${t`Thanks`}

\n`; + this.sendMail(to, subject, html); + } + async sendPasswdResetMail(to, ip, host, lang) { const { t } = getTTag(lang); - - if (!this.enabled) { + if (!this.enabled && !socketEvents.isCluster) { return t`Mail is not configured on the server`; } - const pastMail = this.verifyCodes[to]; - if (pastMail) { - if (Date.now() < pastMail.timestamp + 15 * MINUTE) { - logger.info( - `Password reset mail for ${to} requested by ${ip} - already sent`, - ); - return t`We already sent you a mail with instructions. Please wait before requesting another mail.`; - } + const pastCodeAge = await codeExists(to); + if (pastCodeAge && pastCodeAge < 180) { + logger.info( + `Password reset mail for ${to} requested by ${ip} - already sent`, + ); + return t`We already sent you a mail with instructions. Please wait before requesting another mail.`; } + const reguser = await RegUser.findOne({ where: { email: to } }); - if (pastMail || !reguser) { + if (!reguser) { logger.info( `Password reset mail for ${to} requested by ${ip} - mail not found`, ); @@ -119,68 +140,20 @@ class MailProvider { } */ - logger.info(`Sending Password reset mail to ${to}`); - const code = this.setCode(to); - const restoreUrl = `${host}/reset_password?token=${code}`; - const subject = t`You forgot your password for PixelPlanet? Get a new one here`; - const html = `${t`Hello`},
- ${t`You requested to get a new password. You can change your password within the next 30min here: `} ${t`Reset Password`}. ${t`Or by copying following url:`}
${restoreUrl}\n
- ${t`If you did not request this mail, please just ignore it (the ip that requested this mail was ${ip}).`}
- ${t`Thanks`}

\n`; - this.sendMail(to, subject, html); + const code = setCode(to); + if (this.enabled) { + this.postPasswdResetMail(to, ip, host, lang, code); + } else { + socketEvents.sendMail('pwreset', [to, ip, host, lang, code]); + } return null; } - setCode(email) { - const code = MailProvider.createCode(); - this.verifyCodes[email] = { - code, - timestamp: Date.now(), - }; - return code; - } - - async clearCodes() { - const curTime = Date.now(); - const toDelete = []; - - const mails = Object.keys(this.verifyCodes); - for (let i = 0; i < mails.length; i += 1) { - const iteremail = mails[i]; - if (curTime > this.verifyCodes[iteremail].timestamp + HOUR) { - toDelete.push(iteremail); - } - } - toDelete.forEach((email) => { - logger.info(`Mail Code for ${email} expired`); - delete this.verifyCodes[email]; - }); - } - - // Note: code gets deleted on check - checkCode(code) { - let email = null; - const mails = Object.keys(this.verifyCodes); - for (let i = 0; i < mails.length; i += 1) { - const iteremail = mails[i]; - if (this.verifyCodes[iteremail].code === code) { - email = iteremail; - break; - } - } - if (!email) { - logger.info(`Mail Code ${code} not found.`); + static async verify(email, code) { + const ret = await checkCode(email, code); + if (!ret) { return false; } - logger.info(`Got Mail Code from ${email}.`); - delete this.verifyCodes[email]; - return email; - } - - async verify(code) { - const email = this.checkCode(code); - if (!email) return false; - const reguser = await RegUser.findOne({ where: { email } }); if (!reguser) { logger.error(`${email} does not exist in database`); @@ -193,13 +166,10 @@ class MailProvider { return reguser.name; } - static createCode() { - return randomUUID(); - } - + /* + * we do not use this right now static cleanUsers() { // delete users that requier verification for more than 4 days - /* RegUser.destroy({ where: { verificationReqAt: { @@ -209,8 +179,8 @@ class MailProvider { verified: 0, }, }); - */ } + */ } const mailProvider = new MailProvider(); diff --git a/src/core/PixelCache.js b/src/core/PixelCache.js index 437d2a8..d8b0e64 100644 --- a/src/core/PixelCache.js +++ b/src/core/PixelCache.js @@ -3,7 +3,7 @@ * in bursts per chunk */ -import socketEvents from '../socket/SocketEvents'; +import socketEvents from '../socket/socketEvents'; class PixelCache { PXL_CACHE; diff --git a/src/core/Tile.js b/src/core/Tile.js index a374986..34de45b 100644 --- a/src/core/Tile.js +++ b/src/core/Tile.js @@ -242,6 +242,14 @@ function addIndexedSubtiletoTile( function tileFileName(canvasTileFolder, cell) { const [z, x, y] = cell; const filename = `${canvasTileFolder}/${z}/${x}/${y}.webp`; + try { + const mtime = new Date(fs.statSync(filename).mtime).getTime(); + if (Date.now() - mtime < 120000) { + return null; + } + } catch { + // file doesn't exist + } return filename; } @@ -263,6 +271,10 @@ export async function createZoomTileFromChunk( const canvasSize = canvas.size; const [x, y] = cell; const maxTiledZoom = getMaxTiledZoom(canvasSize); + + const filename = tileFileName(canvasTileFolder, [maxTiledZoom - 1, x, y]); + if (!filename) return true; + const tileRGBBuffer = new Uint8Array( TILE_SIZE * TILE_SIZE * TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL * 3, ); @@ -318,7 +330,6 @@ export async function createZoomTileFromChunk( ); }); - const filename = tileFileName(canvasTileFolder, [maxTiledZoom - 1, x, y]); try { await sharp(tileRGBBuffer, { raw: { @@ -363,6 +374,9 @@ export async function createZoomedTile( ); const [z, x, y] = cell; + const filename = tileFileName(canvasTileFolder, [z, x, y]); + if (!filename) return true; + const startTime = Date.now(); const na = []; @@ -409,7 +423,6 @@ export async function createZoomedTile( ); }); - const filename = tileFileName(canvasTileFolder, [z, x, y]); try { await sharp(tileRGBBuffer, { raw: { diff --git a/src/core/Void.js b/src/core/Void.js index 39efea0..cb537f7 100644 --- a/src/core/Void.js +++ b/src/core/Void.js @@ -5,7 +5,7 @@ * if it reaches the TARGET_RADIUS size, the event is lost * */ -import socketEvents from '../socket/SocketEvents'; +import socketEvents from '../socket/socketEvents'; import PixelUpdate from '../socket/packets/PixelUpdateServer'; import { setPixelByOffset } from './setPixel'; import { TILE_SIZE } from './constants'; diff --git a/src/core/config.js b/src/core/config.js index 4bdbb2b..ca17287 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -20,7 +20,7 @@ export const MAIL_ADDRESS = process.env.MAIL_ADDRESS const TILE_FOLDER_REL = process.env.TILE_FOLDER || 'tiles'; export const TILE_FOLDER = path.join(__dirname, `./${TILE_FOLDER_REL}`); -export const USE_XREALIP = process.env.USE_XREALIP || false; +export const USE_XREALIP = !!process.env.USE_XREALIP; export const BACKUP_URL = process.env.BACKUP_URL || null; export const BACKUP_DIR = process.env.BACKUP_DIR || null; @@ -30,6 +30,7 @@ export const USE_PROXYCHECK = parseInt(process.env.USE_PROXYCHECK, 10) || false; export const { PROXYCHECK_KEY } = process.env; export const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; +export const SHARD_NAME = process.env.SHARD_NAME || null; // Database export const MYSQL_HOST = process.env.MYSQL_HOST || 'localhost'; export const MYSQL_DATABASE = process.env.MYSQL_DATABASE || 'pixelplanet'; @@ -52,6 +53,9 @@ export const APISOCKET_KEY = process.env.APISOCKET_KEY || null; export const ADMIN_IDS = (process.env.ADMIN_IDS) ? process.env.ADMIN_IDS.split(',').map((z) => parseInt(z, 10)) : []; +export const CORS_HOSTS = (process.env.CORS_HOSTS) + ? process.env.CORS_HOSTS.split(',') : []; + export const auth = { // https://developers.facebook.com/ facebook: { diff --git a/src/core/ranking.js b/src/core/ranking.js index 976699b..be46ac4 100644 --- a/src/core/ranking.js +++ b/src/core/ranking.js @@ -6,6 +6,7 @@ import Sequelize from 'sequelize'; import sequelize from '../data/sql/sequelize'; import RegUser from '../data/sql/RegUser'; import { saveDailyTop, loadDailyTop } from '../data/redis/PrevDayTop'; +import socketEvents from '../socket/socketEvents'; import logger from './logger'; import { MINUTE } from './constants'; @@ -33,15 +34,17 @@ class Ranks { async updateRanking() { logger.info('Update pixel rankings'); - // recalculate ranking column - await sequelize.query( - // eslint-disable-next-line max-len - 'SET @r=0; UPDATE Users SET ranking= @r:= (@r + 1) ORDER BY totalPixels DESC;', - ); - await sequelize.query( - // eslint-disable-next-line max-len - 'SET @r=0; UPDATE Users SET dailyRanking= @r:= (@r + 1) ORDER BY dailyTotalPixels DESC;', - ); + if (socketEvents.amIImportant()) { + // recalculate ranking column + await sequelize.query( + // eslint-disable-next-line max-len + 'SET @r=0; UPDATE Users SET ranking= @r:= (@r + 1) ORDER BY totalPixels DESC;', + ); + await sequelize.query( + // eslint-disable-next-line max-len + 'SET @r=0; UPDATE Users SET dailyRanking= @r:= (@r + 1) ORDER BY dailyTotalPixels DESC;', + ); + } // populate dictionaries const ranking = await RegUser.findAll({ attributes: [ @@ -92,6 +95,9 @@ class Ranks { } async resetDailyRanking() { + if (!socketEvents.amIImportant()) { + return; + } this.prevTop = await saveDailyTop(this.ranks.dailyRanking); logger.info('Resetting Daily Ranking'); await RegUser.update({ dailyTotalPixels: 0 }, { where: {} }); diff --git a/src/core/session.js b/src/core/session.js index 33813b2..780d616 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -5,26 +5,36 @@ import expressSession from 'express-session'; import RedisStore from '../utils/connectRedis'; import client from '../data/redis/client'; +import { getHostFromRequest } from '../utils/ip'; import { HOUR, COOKIE_SESSION_NAME } from './constants'; import { SESSION_SECRET } from './config'; export const store = new RedisStore({ client }); -const session = expressSession({ - name: COOKIE_SESSION_NAME, - store, - secret: SESSION_SECRET, - // The best way to know is to check with your store if it implements the touch method. If it does, then you can safely set resave: false - resave: false, - saveUninitialized: false, - cookie: { - path: '/', - httpOnly: true, - secure: false, - // not setting maxAge or expire makes it a non-persisting cookies - maxAge: 30 * 24 * HOUR, - }, -}); +/* + * we cache created session middlewares per domain + */ +const middlewareCache = {}; -export default session; +export default (req, res, next) => { + const domain = getHostFromRequest(req, false, true); + let session = middlewareCache[domain]; + if (!session) { + session = expressSession({ + name: COOKIE_SESSION_NAME, + store, + secret: SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + domain, + httpOnly: true, + secure: false, + maxAge: 30 * 24 * HOUR, + }, + }); + middlewareCache[domain] = session; + } + return session(req, res, next); +}; diff --git a/src/core/tileserver.js b/src/core/tileserver.js index 9a6749c..a299ebb 100644 --- a/src/core/tileserver.js +++ b/src/core/tileserver.js @@ -8,7 +8,7 @@ import { Worker } from 'worker_threads'; import logger from './logger'; import canvases from './canvases'; -import RedisCanvas from '../data/redis/RedisCanvas'; +import socketEvents from '../socket/socketEvents'; import { TILE_FOLDER } from './config'; import { @@ -181,17 +181,16 @@ class CanvasUpdater { } } -export function registerChunkChange(canvasId, chunk) { +socketEvents.on('chunkUpdate', (canvasId, chunk) => { if (CanvasUpdaters[canvasId]) { CanvasUpdaters[canvasId].registerChunkChange(chunk); } -} -RedisCanvas.setChunkChangeCallback(registerChunkChange); +}); /* * starting update loops for canvases */ -export async function startAllCanvasLoops() { +export default function startAllCanvasLoops() { if (!fs.existsSync(`${TILE_FOLDER}`)) fs.mkdirSync(`${TILE_FOLDER}`); const ids = Object.keys(canvases); for (let i = 0; i < ids.length; i += 1) { diff --git a/src/data/redis/RedisCanvas.js b/src/data/redis/RedisCanvas.js index 7f99cc4..c6ef354 100644 --- a/src/data/redis/RedisCanvas.js +++ b/src/data/redis/RedisCanvas.js @@ -4,24 +4,13 @@ import { commandOptions } from 'redis'; import { getChunkOfPixel, getOffsetOfPixel } from '../../core/utils'; +import socketEvents from '../../socket/socketEvents'; import client from './client'; const UINT_SIZE = 'u8'; class RedisCanvas { - // array of callback functions that gets informed about chunk changes - static registerChunkChange = []; - static setChunkChangeCallback(cb) { - RedisCanvas.registerChunkChange.push(cb); - } - - static execChunkChangeCallback(canvasId, cell) { - for (let i = 0; i < RedisCanvas.registerChunkChange.length; i += 1) { - RedisCanvas.registerChunkChange[i](canvasId, cell); - } - } - /* * Get chunk from redis * canvasId integer id of canvas @@ -56,14 +45,14 @@ class RedisCanvas { static async setChunk(i, j, chunk, canvasId) { const key = `ch:${canvasId}:${i}:${j}`; await client.set(key, Buffer.from(chunk.buffer)); - RedisCanvas.execChunkChangeCallback(canvasId, [i, j]); + socketEvents.broadcastChunkUpdate(canvasId, [i, j]); return true; } static async delChunk(i, j, canvasId) { const key = `ch:${canvasId}:${i}:${j}`; await client.del(key); - RedisCanvas.execChunkChangeCallback(canvasId, [i, j]); + socketEvents.broadcastChunkUpdate(canvasId, [i, j]); return true; } @@ -97,8 +86,6 @@ class RedisCanvas { String(color), ], ); - - RedisCanvas.execChunkChangeCallback(canvasId, [i, j]); } static flushPixels() { diff --git a/src/data/redis/client.js b/src/data/redis/client.js index b8ee829..416ba8d 100644 --- a/src/data/redis/client.js +++ b/src/data/redis/client.js @@ -4,13 +4,14 @@ */ import fs from 'fs'; import { createClient, defineScript } from 'redis'; +import { isMainThread } from 'worker_threads'; -import { REDIS_URL } from '../../core/config'; +import { REDIS_URL, SHARD_NAME } from '../../core/config'; const scripts = { placePxl: defineScript({ NUMBER_OF_KEYS: 5, - SCRIPT: fs.readFileSync('./workers/placePixel.lua'), + SCRIPT: fs.readFileSync('./workers/lua/placePixel.lua'), transformArguments(...args) { return args.map((a) => ((typeof a === 'string') ? a : a.toString())); }, @@ -30,10 +31,24 @@ const client = createClient(REDIS_URL.startsWith('redis://') }, ); +/* + * for sending messages via cluster + */ +export const pubsub = { + subscriber: null, + publisher: null, +}; + export const connect = async () => { // eslint-disable-next-line no-console console.log(`Connecting to redis server at ${REDIS_URL}`); await client.connect(); + if (SHARD_NAME && isMainThread) { + const subscriber = client.duplicate(); + await subscriber.connect(); + pubsub.publisher = client; + pubsub.subscriber = subscriber; + } }; export default client; diff --git a/src/data/redis/mailCodes.js b/src/data/redis/mailCodes.js new file mode 100644 index 0000000..0405876 --- /dev/null +++ b/src/data/redis/mailCodes.js @@ -0,0 +1,54 @@ +/* + * + * data saving for hourly events + * + */ +import { randomUUID } from 'crypto'; + +import client from './client'; + +export const PREFIX = 'mail'; +const EXPIRE_TIME = 3600; + +/* + * generate and set mail code + * @param email + * @return code + */ +export function setCode(email) { + const code = randomUUID(); + const key = `${PREFIX}:${email}`; + client.set(key, code, { + EX: EXPIRE_TIME, + }); + return code; +} + +/* + * check if email code is correct + * @param email + * @param code + */ +export async function checkCode(email, code) { + const key = `${PREFIX}:${email}`; + const storedCode = await client.get(key); + if (!storedCode || code !== storedCode) { + return false; + } + client.del(key); + return true; +} + +/* + * check if code exists + * @param email + * @return null if doesn't, age in seconds if exists + */ +export async function codeExists(email) { + const key = `${PREFIX}:${email}`; + const ttl = await client.ttl(key); + if (!ttl) { + return null; + } + return EXPIRE_TIME - ttl; +} diff --git a/src/routes/api/auth/change_mail.js b/src/routes/api/auth/change_mail.js index 1a7ecf3..4594b5c 100644 --- a/src/routes/api/auth/change_mail.js +++ b/src/routes/api/auth/change_mail.js @@ -2,7 +2,7 @@ * request password change */ -import mailProvider from '../../../core/mail'; +import mailProvider from '../../../core/MailProvider'; import { validatePassword, validateEMail } from '../../../utils/validation'; import { getHostFromRequest } from '../../../utils/ip'; diff --git a/src/routes/api/auth/change_name.js b/src/routes/api/auth/change_name.js index c6c3b4c..5a82917 100644 --- a/src/routes/api/auth/change_name.js +++ b/src/routes/api/auth/change_name.js @@ -3,7 +3,7 @@ */ -import socketEvents from '../../../socket/SocketEvents'; +import socketEvents from '../../../socket/socketEvents'; import { RegUser } from '../../../data/sql'; import { validateName } from '../../../utils/validation'; diff --git a/src/routes/api/auth/delete_account.js b/src/routes/api/auth/delete_account.js index 0ff475b..af86b30 100644 --- a/src/routes/api/auth/delete_account.js +++ b/src/routes/api/auth/delete_account.js @@ -2,6 +2,7 @@ * request password change */ +import socketEvents from '../../../socket/socketEvents'; import { RegUser } from '../../../data/sql'; import { validatePassword } from '../../../utils/validation'; import { compareToHash } from '../../../utils/hash'; @@ -35,7 +36,7 @@ export default async (req, res) => { }); return; } - const { id } = user; + const { id, name } = user; const currentPassword = user.regUser.password; if (!currentPassword || !compareToHash(password, currentPassword)) { @@ -58,6 +59,8 @@ export default async (req, res) => { RegUser.destroy({ where: { id } }); + socketEvents.reloadUser(name); + res.status(200); res.json({ success: true, diff --git a/src/routes/api/auth/register.js b/src/routes/api/auth/register.js index 99c9175..da88ea8 100644 --- a/src/routes/api/auth/register.js +++ b/src/routes/api/auth/register.js @@ -2,7 +2,7 @@ import Sequelize from 'sequelize'; import logger from '../../../core/logger'; import { RegUser } from '../../../data/sql'; -import mailProvider from '../../../core/mail'; +import mailProvider from '../../../core/MailProvider'; import getMe from '../../../core/me'; import { getIPFromRequest, getHostFromRequest } from '../../../utils/ip'; import { diff --git a/src/routes/api/auth/resend_verify.js b/src/routes/api/auth/resend_verify.js index 7a4e248..a0399b5 100644 --- a/src/routes/api/auth/resend_verify.js +++ b/src/routes/api/auth/resend_verify.js @@ -2,7 +2,7 @@ * request resend of verification mail */ -import mailProvider from '../../../core/mail'; +import mailProvider from '../../../core/MailProvider'; import { getHostFromRequest } from '../../../utils/ip'; export default async (req, res) => { @@ -26,7 +26,7 @@ export default async (req, res) => { const host = getHostFromRequest(req); - const error = mailProvider.sendVerifyMail(email, name, host, lang); + const error = await mailProvider.sendVerifyMail(email, name, host, lang); if (error) { res.status(400); res.json({ diff --git a/src/routes/api/auth/restore_password.js b/src/routes/api/auth/restore_password.js index 09de660..7f1e5ca 100644 --- a/src/routes/api/auth/restore_password.js +++ b/src/routes/api/auth/restore_password.js @@ -3,7 +3,7 @@ */ -import mailProvider from '../../../core/mail'; +import mailProvider from '../../../core/MailProvider'; import { validateEMail } from '../../../utils/validation'; import { getHostFromRequest } from '../../../utils/ip'; diff --git a/src/routes/api/auth/verify.js b/src/routes/api/auth/verify.js index 0339b4b..4c7a003 100644 --- a/src/routes/api/auth/verify.js +++ b/src/routes/api/auth/verify.js @@ -2,31 +2,36 @@ * verify mail address */ -import socketEvents from '../../../socket/SocketEvents'; +import socketEvents from '../../../socket/socketEvents'; import getHtml from '../../../ssr/RedirectionPage'; import { getHostFromRequest } from '../../../utils/ip'; -import mailProvider from '../../../core/mail'; +import { MailProvider } from '../../../core/MailProvider'; +import { validateEMail } from '../../../utils/validation'; export default async (req, res) => { - const { token } = req.query; + const { email, token } = req.query; const { lang } = req; const { t } = req.ttag; - const name = await mailProvider.verify(token); + const host = getHostFromRequest(req); - if (name) { - // notify websoecket to reconnect user - // thats a bit counter productive because it directly links to the websocket - socketEvents.reloadUser(name); - // --- - const index = getHtml( - t`Mail verification`, - t`You are now verified :)`, - host, lang, - ); - res.status(200).send(index); - } else { - // eslint-disable-next-line max-len - const index = getHtml(t`Mail verification`, t`Your mail verification code is invalid or already expired :(, please request a new one.`, host, lang); - res.status(400).send(index); + const error = validateEMail(email); + if (!error) { + const name = await MailProvider.verify(email, token); + if (name) { + // notify websoecket to reconnect user + // thats a bit counter productive because it directly links to the websocket + socketEvents.reloadUser(name); + // --- + const index = getHtml( + t`Mail verification`, + t`You are now verified :)`, + host, lang, + ); + res.status(200).send(index); + return; + } } + // eslint-disable-next-line max-len + const index = getHtml(t`Mail verification`, t`Your mail verification code is invalid or already expired :(, please request a new one.`, host, lang); + res.status(400).send(index); }; diff --git a/src/routes/api/block.js b/src/routes/api/block.js index 3687990..24e7778 100644 --- a/src/routes/api/block.js +++ b/src/routes/api/block.js @@ -5,7 +5,7 @@ */ import logger from '../../core/logger'; -import socketEvents from '../../socket/SocketEvents'; +import socketEvents from '../../socket/socketEvents'; import { RegUser, UserBlock, Channel } from '../../data/sql'; async function block(req, res) { diff --git a/src/routes/api/blockdm.js b/src/routes/api/blockdm.js index 4587f6f..652c3a9 100644 --- a/src/routes/api/blockdm.js +++ b/src/routes/api/blockdm.js @@ -4,7 +4,7 @@ * */ import logger from '../../core/logger'; -import socketEvents from '../../socket/SocketEvents'; +import socketEvents from '../../socket/socketEvents'; async function blockdm(req, res) { const { block } = req.body; diff --git a/src/routes/api/chathistory.js b/src/routes/api/chathistory.js index 3f526aa..baa2a82 100644 --- a/src/routes/api/chathistory.js +++ b/src/routes/api/chathistory.js @@ -7,11 +7,6 @@ import chatProvider from '../../core/ChatProvider'; async function chatHistory(req, res) { let { cid, limit } = req.query; - res.set({ - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }); if (!cid || !limit) { res.status(400); @@ -42,19 +37,10 @@ async function chatHistory(req, res) { return; } - // try { const history = await chatProvider.getHistory(cid, limit); res.json({ history, }); - /* - } catch { - res.status(500); - res.json({ - errors: ['Can not fetch messages'], - }); - } - */ } export default chatHistory; diff --git a/src/routes/api/index.js b/src/routes/api/index.js index 7e9f23d..d91122c 100644 --- a/src/routes/api/index.js +++ b/src/routes/api/index.js @@ -17,10 +17,19 @@ import blockdm from './blockdm'; import modtools from './modtools'; import baninfo from './baninfo'; import getiid from './getiid'; - +import shards from './shards'; const router = express.Router(); +// set cache-control +router.use((req, res, next) => { + res.set({ + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Expires: '0', + }); + next(); +}); + router.use(express.json()); // eslint-disable-next-line no-unused-vars @@ -35,6 +44,7 @@ router.use((err, req, res, next) => { router.post('/captcha', captcha); router.get('/baninfo', baninfo); router.get('/getiid', getiid); +router.get('/shards', shards); /* * get user session diff --git a/src/routes/api/leavechan.js b/src/routes/api/leavechan.js index 9e67e0d..a7d34b2 100644 --- a/src/routes/api/leavechan.js +++ b/src/routes/api/leavechan.js @@ -5,7 +5,7 @@ */ import logger from '../../core/logger'; -import socketEvents from '../../socket/SocketEvents'; +import socketEvents from '../../socket/socketEvents'; async function leaveChan(req, res) { const channelId = parseInt(req.body.channelId, 10); diff --git a/src/routes/api/me.js b/src/routes/api/me.js index a2cd52c..ba871b4 100644 --- a/src/routes/api/me.js +++ b/src/routes/api/me.js @@ -24,10 +24,6 @@ export default async (req, res, next) => { } // https://stackoverflow.com/questions/49547/how-to-control-web-page-caching-across-all-browsers - res.set({ - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Expires: '0', - }); res.json(userdata); } catch (error) { next(error); diff --git a/src/routes/api/shards.js b/src/routes/api/shards.js new file mode 100644 index 0000000..4fd641e --- /dev/null +++ b/src/routes/api/shards.js @@ -0,0 +1,20 @@ +/* + * print information for shards + */ +import socketEvents from '../../socket/socketEvents'; + +async function shards(req, res, next) { + try { + if (!socketEvents.isCluster) { + res.status(400).json({ + errors: ['Not running as cluster'], + }); + return; + } + res.status(200).json(socketEvents.shardOnlineCounters); + } catch (err) { + next(err); + } +} + +export default shards; diff --git a/src/routes/chunks.js b/src/routes/chunks.js index 40b35b3..8b24803 100644 --- a/src/routes/chunks.js +++ b/src/routes/chunks.js @@ -7,11 +7,11 @@ import etag from 'etag'; import RedisCanvas from '../data/redis/RedisCanvas'; import logger from '../core/logger'; +import socketEvents from '../socket/socketEvents'; const chunkEtags = new Map(); -RedisCanvas.setChunkChangeCallback((canvasId, cell) => { - const [x, y] = cell; - chunkEtags.delete(`${canvasId}:${x}:${y}`); +socketEvents.on('chunkUpdate', (canvasId, [i, j]) => { + chunkEtags.delete(`${canvasId}:${i}:${j}`); }); /* @@ -27,6 +27,9 @@ export default async (req, res, next) => { const x = parseInt(paramX, 10); const y = parseInt(paramY, 10); try { + res.set({ + 'Access-Control-allow-origin': '*', + }); // botters where using cachebreakers to update via chunk API // lets not allow that for now if (Object.keys(req.query).length !== 0) { diff --git a/src/routes/index.js b/src/routes/index.js index 4804df3..4b6a90a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -18,6 +18,7 @@ import api from './api'; import { assets } from '../core/assets'; import { expressTTag } from '../core/ttag'; +import corsMiddleware from '../utils/corsMiddleware'; import generateGlobePage from '../ssr/Globe'; import generatePopUpPage from '../ssr/PopUp'; import generateMainPage from '../ssr/Main'; @@ -29,37 +30,18 @@ import { GUILDED_INVITE } from '../core/config'; const router = express.Router(); /* - * void info + * Serving Chunks */ -router.get('/void', voidl); - -/* - * ranking of pixels placed - * daily and total - */ -router.get('/ranking', ranking); - -/* - * give: date per query - * returns: array of HHMM backups available - */ -router.get('/history', history); +router.get( + '/chunks/:c([0-9]+)/:x([0-9]+)/:y([0-9]+)(/)?:z([0-9]+)?.bmp', + chunks, +); /* * zoomed tiles */ router.use('/tiles', tiles); -/* - * adminapi - */ -router.use('/adminapi', adminapi); - -/* - * serve captcha - */ -router.get('/captcha.svg', captcha); - /* * public folder * (this should be served with nginx or other webserver) @@ -77,12 +59,9 @@ router.use('/guilded', (req, res) => { }); /* - * Serving Chunks + * adminapi */ -router.get( - '/chunks/:c([0-9]+)/:x([0-9]+)/:y([0-9]+)(/)?:z([0-9]+)?.bmp', - chunks, -); +router.use('/adminapi', adminapi); /* * Following with translations @@ -90,16 +69,6 @@ router.get( */ router.use(expressTTag); -/* - * API calls - */ -router.use('/api', api); - -/* - * Password Reset Link - */ -router.use('/reset_password', resetPassword); - // // 3D Globe (react generated) // ----------------------------------------------------------------------------- @@ -149,7 +118,7 @@ router.use( return; } - res.status(200).send(generatePopUpPage(req.lang)); + res.status(200).send(generatePopUpPage(req)); }, ); @@ -173,7 +142,47 @@ router.get('/', (req, res) => { return; } - res.status(200).send(generateMainPage(req.lang)); + res.status(200).send(generateMainPage(req)); }); + +/* + * Password Reset Link + */ +router.use('/reset_password', resetPassword); + +/* + * Following with CORS + * --------------------------------------------------------------------------- + */ +router.use(corsMiddleware); + +/* + * API calls + */ +router.use('/api', api); + +/* + * void info + */ +router.get('/void', voidl); + +/* + * ranking of pixels placed + * daily and total + */ +router.get('/ranking', ranking); + +/* + * give: date per query + * returns: array of HHMM backups available + */ +router.get('/history', history); + +/* + * serve captcha + */ +router.get('/captcha.svg', captcha); + + export default router; diff --git a/src/routes/reset_password.js b/src/routes/reset_password.js index 68a788b..7d4c0ef 100644 --- a/src/routes/reset_password.js +++ b/src/routes/reset_password.js @@ -8,7 +8,7 @@ import express from 'express'; import logger from '../core/logger'; import getPasswordResetHtml from '../ssr/PasswordReset'; -import mailProvider from '../core/mail'; +import mailProvider from '../core/MailProvider'; import { RegUser } from '../data/sql'; diff --git a/src/routes/tiles.js b/src/routes/tiles.js index 18ec8ea..7551f88 100644 --- a/src/routes/tiles.js +++ b/src/routes/tiles.js @@ -19,6 +19,9 @@ const router = express.Router(); */ router.use('/:c([0-9]+)/:z([0-9]+)/:x([0-9]+)/:y([0-9]+).webp', (req, res, next) => { + res.set({ + 'Access-Control-allow-origin': '*', + }); const { c: id } = req.params; const canvas = canvases[id]; if (!canvas) { diff --git a/src/server.js b/src/server.js index dbba8d3..5a4847c 100644 --- a/src/server.js +++ b/src/server.js @@ -17,14 +17,16 @@ import chatProvider from './core/ChatProvider'; import rpgEvent from './core/RpgEvent'; import canvasCleaner from './core/CanvasCleaner'; +import socketEvents from './socket/socketEvents'; import SocketServer from './socket/SocketServer'; import APISocketServer from './socket/APISocketServer'; - -import { PORT, HOST, HOURLY_EVENT } from './core/config'; +import { + PORT, HOST, HOURLY_EVENT, SHARD_NAME, +} from './core/config'; import { SECOND } from './core/constants'; -import { startAllCanvasLoops } from './core/tileserver'; +import startAllCanvasLoops from './core/tileserver'; const app = express(); app.disable('x-powered-by'); @@ -78,15 +80,11 @@ app.use(routes); sequelize.sync({ alter: { drop: false } }) // connect to redis .then(connectRedis) - .then(() => { - rankings.initialize(); + .then(async () => { chatProvider.initialize(); startAllCanvasLoops(); usersocket.initialize(); apisocket.initialize(); - if (HOURLY_EVENT) { - rpgEvent.initialize(); - } canvasCleaner.initialize(); // start http server const startServer = () => { @@ -108,4 +106,22 @@ sequelize.sync({ alter: { drop: false } }) startServer(); }, 5000); }); + }) + .then(async () => { + await socketEvents.initialize(); + }) + .then(async () => { + /* + * initializers that rely on the cluster being fully established + * i.e. to know if it is the shard that runs the event + */ + if (socketEvents.isCluster && socketEvents.amIImportant()) { + logger.info('I am the main shard'); + } + rankings.initialize(); + if (HOURLY_EVENT && !SHARD_NAME) { + // TODO make it wok in a cluster + logger.info('Initializing RpgEvent'); + rpgEvent.initialize(); + } }); diff --git a/src/socket/APISocketServer.js b/src/socket/APISocketServer.js index 7fc07da..d12088e 100644 --- a/src/socket/APISocketServer.js +++ b/src/socket/APISocketServer.js @@ -9,7 +9,7 @@ import WebSocket from 'ws'; -import socketEvents from './SocketEvents'; +import socketEvents from './socketEvents'; import chatProvider, { ChatProvider } from '../core/ChatProvider'; import { RegUser } from '../data/sql'; import { getIPFromRequest } from '../utils/ip'; @@ -61,7 +61,6 @@ class APISocketServer { this.ping = this.ping.bind(this); this.broadcastChatMessage = this.broadcastChatMessage.bind(this); - socketEvents.onAsync('broadcast', this.broadcast); socketEvents.onAsync('onlineCounter', this.broadcastOnlineCounter); socketEvents.onAsync('pixelUpdate', this.broadcastPixelBuffer); socketEvents.onAsync('chatMessage', this.broadcastChatMessage); diff --git a/src/socket/MessageBroker.js b/src/socket/MessageBroker.js new file mode 100644 index 0000000..09447f2 --- /dev/null +++ b/src/socket/MessageBroker.js @@ -0,0 +1,254 @@ +/* + * sends messages to other ppfun instances + * to work as cluster + */ + +/* eslint-disable no-console */ + +import { SHARD_NAME } from '../core/config'; +import SocketEvents from './SockEvents'; +import OnlineCounter from './packets/OnlineCounter'; +import PixelUpdate from './packets/PixelUpdateServer'; +import PixelUpdateMB from './packets/PixelUpdateMB'; +import ChunkUpdate from './packets/ChunkUpdate'; +import { pubsub } from '../data/redis/client'; + + +class MessageBroker extends SocketEvents { + constructor() { + super(); + this.isCluster = true; + this.thisShard = SHARD_NAME; + /* + * all other shards + */ + this.shards = {}; + /* + * online counter of all shards including ourself + */ + this.shardOnlineCounters = []; + this.publisher = { + publish: () => {}, + }; + this.subscriber = { + subscribe: () => {}, + unsubscribe: () => {}, + }; + this.checkHealth = this.checkHealth.bind(this); + setInterval(this.checkHealth, 10000); + } + + // TODO imprement shared storage that is run by main shard + + async initialize() { + /* + * broadcast channel for staus messages between shards + */ + this.publisher = pubsub.publisher; + this.subscriber = pubsub.subscriber; + await this.subscriber.subscribe('bc', (...args) => { + this.onShardBCMessage(...args); + }); + // give other shards 30s to announce themselves + await new Promise((resolve) => { + setTimeout(resolve, 25000); + }); + console.log('CLUSTER: Initialized message broker'); + } + + async onShardBCMessage(message) { + try { + /* + * messages from own shard get dropped + */ + if (!message || message.startsWith(this.thisShard)) { + return; + } + const comma = message.indexOf(','); + /* + * any other package in the form of 'shard:type,JSONArrayData' + * straight sent over websocket + */ + if (~comma) { + console.log('CLUSTER: Broadcast', message); + const key = message.slice(message.indexOf(':') + 1, comma); + const val = JSON.parse(message.slice(comma + 1)); + super.emit(key, ...val); + return; + } + if (!this.shards[message]) { + console.log(`CLUSTER: Shard ${message} connected`); + await this.subscriber.subscribe( + message, + (buffer) => this.onShardBinaryMessage(buffer, message), + true, + ); + // immediately give new shards informations + this.publisher.publish('bc', this.thisShard); + } + this.shards[message] = Date.now(); + return; + } catch (err) { + console.error(`CLUSTER: Error on broadcast message: ${err.message}`); + } + } + + getLowestActiveShard() { + let lowest = 0; + let lShard = null; + this.shardOnlineCounters.forEach((shardData) => { + const [shard, cnt] = shardData; + if (cnt.total < lowest || !lShard) { + lShard = shard; + lowest = cnt.total; + } + }); + return lShard || this.thisShard; + } + + amIImportant() { + /* + * important main shard does tasks like running RpgEvent + * or updating rankings + */ + return !this.shardOnlineCounters[0] + || this.shardOnlineCounters[0][0] === this.thisShard; + } + + updateShardOnlineCounter(shard, cnt) { + const shardCounter = this.shardOnlineCounters.find( + (c) => c[0] === shard, + ); + if (!shardCounter) { + this.shardOnlineCounters.push([shard, cnt]); + this.shardOnlineCounters.sort((a, b) => a[0].localeCompare(b[0])); + } else { + shardCounter[1] = cnt; + } + this.sumOnlineCounters(); + } + + onShardBinaryMessage(buffer, shard) { + if (buffer.byteLength === 0) return; + const opcode = buffer[0]; + try { + switch (opcode) { + case PixelUpdateMB.OP_CODE: { + const puData = PixelUpdateMB.hydrate(buffer); + super.emit('pixelUpdate', ...puData); + const chunkId = puData[1]; + const chunk = [chunkId >> 8, chunkId & 0xFF]; + super.emit('chunkUpdate', puData[0], chunk); + break; + } + case ChunkUpdate.OP_CODE: { + super.emit('chunkUpdate', ...ChunkUpdate.hydrate(buffer)); + break; + } + case OnlineCounter.OP_CODE: { + const data = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.length, + ); + const cnt = OnlineCounter.hydrate(data); + this.updateShardOnlineCounter(shard, cnt); + break; + } + default: + // nothing + } + } catch (err) { + // eslint-disable-next-line max-len + console.error(`CLUSTER: Error on binery message of shard ${shard}: ${err.message}`); + } + } + + sumOnlineCounters() { + const newCounter = {}; + this.shardOnlineCounters.forEach((shardData) => { + const [, cnt] = shardData; + Object.keys(cnt).forEach((canv) => { + const num = cnt[canv]; + if (newCounter[canv]) { + newCounter[canv] += num; + } else { + newCounter[canv] = num; + } + }); + }); + this.onlineCounter = newCounter; + } + + emit(key, ...args) { + super.emit(key, ...args); + const msg = `${this.thisShard}:${key},${JSON.stringify(args)}`; + this.publisher.publish('bc', msg); + } + + /* + * broadcast pixel message via websocket + * @param canvasId number ident of canvas + * @param chunkid number id consisting of i,j chunk coordinates + * @param pxls buffer with offset and color of one or more pixels + */ + broadcastPixels( + canvasId, + chunkId, + pixels, + ) { + const i = chunkId >> 8; + const j = chunkId & 0xFF; + this.publisher.publish( + this.thisShard, + PixelUpdateMB.dehydrate(canvasId, i, j, pixels), + ); + const buffer = PixelUpdate.dehydrate(i, j, pixels); + super.emit('pixelUpdate', canvasId, chunkId, buffer); + super.emit('chunkUpdate', canvasId, [i, j]); + } + + broadcastChunkUpdate( + canvasId, + chunk, + ) { + this.publisher.publish( + this.thisShard, + ChunkUpdate.dehydrate(canvasId, chunk), + ); + super.emit('chunkUpdate', canvasId, chunk); + } + + broadcastOnlineCounter(online) { + this.updateShardOnlineCounter(this.thisShard, online); + let buffer = OnlineCounter.dehydrate(online); + // send our online counter to other shards + this.publisher.publish(this.thisShard, buffer); + // send total counter to our players + buffer = OnlineCounter.dehydrate(this.onlineCounter); + super.emit('onlineCounter', buffer); + } + + checkHealth() { + // remove disconnected shards + const threshold = Date.now() - 30000; + const { shards } = this; + Object.keys(shards).forEach((shard) => { + if (shards[shard] < threshold) { + console.log(`CLUSTER: Shard ${shard} disconnected`); + delete shards[shard]; + const counterIndex = this.shardOnlineCounters.findIndex( + (c) => c[0] === shard, + ); + if (~counterIndex) { + this.shardOnlineCounters.splice(counterIndex, 1); + } + this.subscriber.unsubscribe(shard); + } + }); + // send keep alive to others + this.publisher.publish('bc', this.thisShard); + } +} + +export default MessageBroker; diff --git a/src/socket/SocketEvents.js b/src/socket/SockEvents.js similarity index 76% rename from src/socket/SocketEvents.js rename to src/socket/SockEvents.js index d181537..2e0a498 100644 --- a/src/socket/SocketEvents.js +++ b/src/socket/SockEvents.js @@ -22,6 +22,21 @@ class SocketEvents extends EventEmitter { }; } + // eslint-disable-next-line class-methods-use-this + async initialize() { + // nothing, only for child classes + } + + // eslint-disable-next-line class-methods-use-this + getLowestActiveShard() { + return null; + } + + // eslint-disable-next-line class-methods-use-this + amIImportant() { + return true; + } + /* * async event */ @@ -33,14 +48,6 @@ class SocketEvents extends EventEmitter { }); } - /* - * broadcast message via websocket - * @param message Buffer Message to send - */ - broadcast(message) { - this.emit('broadcast', message); - } - /* * broadcast pixel message via websocket * @param canvasId number ident of canvas @@ -52,8 +59,34 @@ class SocketEvents extends EventEmitter { chunkId, pixels, ) { - const buffer = PixelUpdate.dehydrate(chunkId, pixels); + const i = chunkId >> 8; + const j = chunkId & 0xFF; + const buffer = PixelUpdate.dehydrate(i, j, pixels); this.emit('pixelUpdate', canvasId, chunkId, buffer); + this.emit('chunkUpdate', canvasId, [i, j]); + } + + /* + * chunk updates from event, image upload, etc. + * everything thats not a pixelUpdate and changes chunks + * @param canvasId + * @param chunk [i,j] chunk coordinates + */ + broadcastChunkUpdate( + canvasId, + chunk, + ) { + this.emit('chunkUpdate', canvasId, chunk); + } + + /* + * ask other shards to send email for us, + * only used when USE_MAILER is false + * @param type type of mail to send + * @param args + */ + sendMail(...args) { + this.emit('mail', ...args); } /* @@ -169,4 +202,4 @@ class SocketEvents extends EventEmitter { } } -export default new SocketEvents(); +export default SocketEvents; diff --git a/src/socket/SocketClient.js b/src/socket/SocketClient.js index 162a435..25f4a81 100644 --- a/src/socket/SocketClient.js +++ b/src/socket/SocketClient.js @@ -14,6 +14,7 @@ import RegisterMultipleChunks from './packets/RegisterMultipleChunks'; import DeRegisterChunk from './packets/DeRegisterChunk'; import ChangedMe from './packets/ChangedMe'; import Ping from './packets/Ping'; +import { shardHost } from '../store/actions/fetch'; const chunks = []; @@ -43,9 +44,10 @@ class SocketClient extends EventEmitter { console.log('WebSocket already open, not starting'); } this.timeLastConnecting = Date.now(); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${protocol}//${window.location.hostname}${ - window.location.port ? `:${window.location.port}` : '' + const url = `${ + window.location.protocol === 'https:' ? 'wss:' : 'ws:' + }//${ + shardHost || window.location.host }/ws`; this.ws = new WebSocket(url); this.ws.binaryType = 'arraybuffer'; diff --git a/src/socket/SocketServer.js b/src/socket/SocketServer.js index dace027..a187bfa 100644 --- a/src/socket/SocketServer.js +++ b/src/socket/SocketServer.js @@ -19,7 +19,7 @@ import DeRegisterMultipleChunks from './packets/DeRegisterMultipleChunks'; import ChangedMe from './packets/ChangedMe'; import OnlineCounter from './packets/OnlineCounter'; -import socketEvents from './SocketEvents'; +import socketEvents from './socketEvents'; import chatProvider, { ChatProvider } from '../core/ChatProvider'; import authenticateClient from './authenticateClient'; import { drawByOffsets } from '../core/draw'; @@ -111,7 +111,6 @@ class SocketServer { }); }); - socketEvents.on('broadcast', this.broadcast); socketEvents.on('onlineCounter', this.broadcast); socketEvents.on('pixelUpdate', this.broadcastPixelBuffer); socketEvents.on('reloadUser', this.reloadUser); @@ -189,9 +188,12 @@ class SocketServer { } // CORS const { origin } = headers; - if (!origin || !origin.endsWith(getHostFromRequest(request, false))) { + const host = getHostFromRequest(request, false, true); + if (!origin + || !`.${origin.slice(origin.indexOf('//') + 2)}`.endsWith(host) + ) { // eslint-disable-next-line max-len - logger.info(`Rejected CORS request on websocket from ${ip} via ${headers.origin}, expected ${getHostFromRequest(request, false)}`); + logger.info(`Rejected CORS request on websocket from ${ip} via ${headers.origin}, expected ${getHostFromRequest(request, false, true)}`); socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return; @@ -433,9 +435,6 @@ class SocketServer { * if DM channel, make sure that other user has DM open * (needed because we allow user to leave one-sided * and auto-join on message) - * TODO: if we scale and have multiple websocket servers at some point - * this might be an issue. We would hve to make a shared list of online - * users and act based on that on 'chatMessage' event */ const dmUserId = chatProvider.checkIfDm(user, channelId); if (dmUserId) { @@ -443,11 +442,17 @@ class SocketServer { if (!dmWs || !chatProvider.userHasChannelAccess(dmWs.user, channelId) ) { - await ChatProvider.addUserToChannel( - dmUserId, - channelId, - [ws.name, 1, Date.now(), user.id], - ); + // TODO this is really ugly + // DMS have to be rethought + if (!user.addedDM) user.addedDM = []; + if (!user.addedDM.includes(dmUserId)) { + await ChatProvider.addUserToChannel( + dmUserId, + channelId, + [ws.name, 1, Date.now(), user.id], + ); + user.addedDM.push(dmUserId); + } } } diff --git a/src/socket/packets/ChunkUpdate.js b/src/socket/packets/ChunkUpdate.js new file mode 100644 index 0000000..b03d30a --- /dev/null +++ b/src/socket/packets/ChunkUpdate.js @@ -0,0 +1,30 @@ +/* + * notify that chunk changed + * (not sent over websocket, server only) + */ + +const OP_CODE = 0xC4; + +export default { + OP_CODE, + /* + * @return canvasId, [i, j] + */ + hydrate(data) { + const canvasId = data[1]; + const chunk = [data[2], data[3]]; + return [canvasId, chunk]; + }, + /* + * @param canvasId, + * chunkid id consisting of chunk coordinates + */ + dehydrate(canvasId, [i, j]) { + return Buffer.from({ + OP_CODE, + canvasId, + i, + j, + }); + }, +}; diff --git a/src/socket/packets/PixelUpdateMB.js b/src/socket/packets/PixelUpdateMB.js new file mode 100644 index 0000000..5a1245a --- /dev/null +++ b/src/socket/packets/PixelUpdateMB.js @@ -0,0 +1,44 @@ +/* + * Packet for sending and receiving pixels over Message Broker between shards + * Multiple pixels can be sent at once + * + */ + +const OP_CODE = 0xC1; + +export default { + OP_CODE, + /* + * returns info and PixelUpdate package to send to clients + */ + hydrate(data) { + const canvasId = data[1]; + data.writeUInt8(OP_CODE, 1); + const chunkId = data.readUInt16BE(2); + const pixelUpdate = Buffer.from( + data.buffer, + data.byteOffset + 1, + data.length - 1, + ); + return [ + canvasId, + chunkId, + pixelUpdate, + ]; + }, + + /* + * @param canvasId + * @param chunkId id consisting of chunk coordinates + * @param pixels Buffer with offset and color of one or more pixels + */ + dehydrate(canvasId, i, j, pixels) { + const index = new Uint8Array([ + OP_CODE, + canvasId, + i, + j, + ]); + return Buffer.concat([index, pixels]); + }, +}; diff --git a/src/socket/packets/PixelUpdateServer.js b/src/socket/packets/PixelUpdateServer.js index db52bd0..56f312a 100644 --- a/src/socket/packets/PixelUpdateServer.js +++ b/src/socket/packets/PixelUpdateServer.js @@ -43,8 +43,8 @@ export default { * @param chunkId id consisting of chunk coordinates * @param pixels Buffer with offset and color of one or more pixels */ - dehydrate(chunkId, pixels) { - const index = new Uint8Array([OP_CODE, chunkId >> 8, chunkId & 0xFF]); + dehydrate(i, j, pixels) { + const index = new Uint8Array([OP_CODE, i, j]); return Buffer.concat([index, pixels]); }, }; diff --git a/src/socket/socketEvents.js b/src/socket/socketEvents.js new file mode 100644 index 0000000..c72325d --- /dev/null +++ b/src/socket/socketEvents.js @@ -0,0 +1,10 @@ +import SocketEvents from './SockEvents'; +import MessageBroker from './MessageBroker'; +import { SHARD_NAME } from '../core/config'; + +/* + * if we are a shard in a cluster, do messaging to others via redis + */ +const socketEvents = (SHARD_NAME) ? new MessageBroker() : new SocketEvents(); + +export default socketEvents; diff --git a/src/ssr/Main.jsx b/src/ssr/Main.jsx index 19e851d..3e3afbd 100644 --- a/src/ssr/Main.jsx +++ b/src/ssr/Main.jsx @@ -8,8 +8,9 @@ import { langCodeToCC } from '../utils/location'; import ttags, { getTTag } from '../core/ttag'; import { styleassets, assets } from '../core/assets'; - +import socketEvents from '../socket/socketEvents'; import { BACKUP_URL } from '../core/config'; +import { getHostFromRequest } from '../utils/ip'; /* * generate language list @@ -35,14 +36,19 @@ if (BACKUP_URL) { * @param lang language code * @return html of mainpage */ -function generateMainPage(lang) { +function generateMainPage(req) { + const { lang } = req; + const host = getHostFromRequest(req, false); const ssvR = { ...ssv, + shard: (host.startsWith(`${socketEvents.thisShard}.`)) + ? '' : socketEvents.getLowestActiveShard(), lang: lang === 'default' ? 'en' : lang, }; const scripts = (assets[`client-${lang}`]) ? assets[`client-${lang}`].js : assets.client.js; + const { t } = getTTag(lang); const html = ` diff --git a/src/ssr/PopUp.jsx b/src/ssr/PopUp.jsx index a572864..43067ee 100644 --- a/src/ssr/PopUp.jsx +++ b/src/ssr/PopUp.jsx @@ -7,10 +7,10 @@ import { langCodeToCC } from '../utils/location'; import ttags, { getTTag } from '../core/ttag'; - -/* this will be set by webpack */ +import socketEvents from '../socket/socketEvents'; import { styleassets, assets } from '../core/assets'; import { BACKUP_URL } from '../core/config'; +import { getHostFromRequest } from '../utils/ip'; /* * generate language list @@ -35,9 +35,13 @@ if (BACKUP_URL) { * @param lang language code * @return html of mainpage */ -function generatePopUpPage(lang) { +function generatePopUpPage(req) { + const { lang } = req; + const host = getHostFromRequest(req); const ssvR = { ...ssv, + shard: (host.startsWith(`${socketEvents.thisShard}.`)) + ? null : socketEvents.getLowestActiveShard(), lang: lang === 'default' ? 'en' : lang, }; const script = (assets[`popup-${lang}`]) diff --git a/src/store/actions/fetch.js b/src/store/actions/fetch.js index 923e54b..5ba55ff 100644 --- a/src/store/actions/fetch.js +++ b/src/store/actions/fetch.js @@ -8,17 +8,30 @@ import { t } from 'ttag'; import { dateToString } from '../../core/utils'; +export const shardHost = (function getShardHost() { + if (!window.ssv || !window.ssv.shard) { + return ''; + } + const hostParts = window.location.host.split('.'); + if (hostParts.length > 2) { + hostParts.shift(); + } + return `${window.ssv.shard}.${hostParts.join('.')}`; +}()); +export const shardOrigin = shardHost + && `${window.location.protocol}//${shardHost}`; + /* * Adds customizeable timeout to fetch * defaults to 8s */ -async function fetchWithTimeout(resource, options = {}) { - const { timeout = 8000 } = options; +async function fetchWithTimeout(url, options = {}) { + const { timeout = 10000 } = options; const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); - const response = await fetch(resource, { + const response = await fetch(url, { ...options, signal: controller.signal, }); @@ -62,11 +75,19 @@ async function parseAPIresponse(response) { * @param body Body of request * @return Object with response or error Array */ -async function makeAPIPOSTRequest(url, body) { +async function makeAPIPOSTRequest( + url, + body, + credentials = true, + addShard = true, +) { + if (addShard) { + url = `${shardOrigin}${url}`; + } try { const response = await fetchWithTimeout(url, { method: 'POST', - credentials: 'include', + credentials: (credentials) ? 'include' : 'omit', headers: { 'Content-Type': 'application/json', }, @@ -86,10 +107,17 @@ async function makeAPIPOSTRequest(url, body) { * @param url URL of get api endpoint * @return Object with response or error Array */ -async function makeAPIGETRequest(url) { +async function makeAPIGETRequest( + url, + credentials = true, + addShard = true, +) { + if (addShard) { + url = `${shardOrigin}${url}`; + } try { const response = await fetchWithTimeout(url, { - credentials: 'include', + credentials: (credentials) ? 'include' : 'omit', }); return parseAPIresponse(response); @@ -193,8 +221,10 @@ export async function requestSolveCaptcha(text, captchaid) { export async function requestHistoricalTimes(day, canvasId) { try { const date = dateToString(day); - const url = `history?day=${date}&id=${canvasId}`; + // Not going over shard url + const url = `/history?day=${date}&id=${canvasId}`; const response = await fetchWithTimeout(url, { + credentials: 'omit', timeout: 45000, }); if (response.status !== 200) { @@ -212,6 +242,19 @@ export async function requestHistoricalTimes(day, canvasId) { } } +export async function requestChatMessages(cid) { + const response = await fetch( + `${shardOrigin}/api/chathistory?cid=${cid}&limit=50`, + { credentials: 'include' }, + ); + // timeout in order to not spam api requests and get rate limited + if (response.ok) { + const { history } = await response.json(); + return history; + } + return null; +} + export function requestPasswordChange(newPassword, password) { return makeAPIPOSTRequest( '/api/auth/change_passwd', @@ -278,7 +321,8 @@ export function requestDeleteAccount(password) { export function requestRankings() { return makeAPIGETRequest( - 'ranking', + '/ranking', + false, ); } diff --git a/src/store/actions/thunks.js b/src/store/actions/thunks.js index 83a437c..cb93a02 100644 --- a/src/store/actions/thunks.js +++ b/src/store/actions/thunks.js @@ -7,6 +7,7 @@ import { requestBlockDm, requestLeaveChan, requestRankings, + requestChatMessages, requestMe, } from './fetch'; @@ -89,21 +90,12 @@ export function fetchMe() { }; } -export function fetchChatMessages( - cid, -) { +export function fetchChatMessages(cid) { return async (dispatch) => { dispatch(setChatFetching(true)); - const response = await fetch(`/api/chathistory?cid=${cid}&limit=50`, { - credentials: 'include', - }); - - /* - * timeout in order to not spam api requests and get rate limited - */ - if (response.ok) { + const history = await requestChatMessages(cid); + if (history) { setTimeout(() => { dispatch(setChatFetching(false)); }, 500); - const { history } = await response.json(); dispatch(receiveChatHistory(cid, history)); } else { setTimeout(() => { dispatch(setChatFetching(false)); }, 5000); diff --git a/src/ui/ChunkLoader2D.js b/src/ui/ChunkLoader2D.js index e999c57..00fd0da 100644 --- a/src/ui/ChunkLoader2D.js +++ b/src/ui/ChunkLoader2D.js @@ -4,6 +4,7 @@ import ChunkRGB from './ChunkRGB'; import { TILE_SIZE, TILE_ZOOM_LEVEL } from '../core/constants'; +import { shardOrigin } from '../store/actions/fetch'; import { loadingTiles, loadImage, @@ -279,7 +280,7 @@ class ChunkLoader { const center = [zoom, cx, cy]; this.store.dispatch(requestBigChunk(center)); try { - const url = `chunks/${this.canvasId}/${cx}/${cy}.bmp`; + const url = `${shardOrigin}/chunks/${this.canvasId}/${cx}/${cy}.bmp`; const response = await fetch(url); if (response.ok) { const arrayBuffer = await response.arrayBuffer(); @@ -303,7 +304,8 @@ class ChunkLoader { const center = [zoom, cx, cy]; this.store.dispatch(requestBigChunk(center)); try { - const url = `tiles/${this.canvasId}/${zoom}/${cx}/${cy}.webp`; + // eslint-disable-next-line max-len + const url = `${shardOrigin}/tiles/${this.canvasId}/${zoom}/${cx}/${cy}.webp`; const img = await loadImage(url); chunkRGB.fromImage(img); this.store.dispatch(receiveBigChunk(center)); diff --git a/src/ui/ChunkLoader3D.js b/src/ui/ChunkLoader3D.js index dbc6259..8ec9053 100644 --- a/src/ui/ChunkLoader3D.js +++ b/src/ui/ChunkLoader3D.js @@ -3,6 +3,7 @@ * */ +import Chunk from './ChunkRGB3D'; import { requestBigChunk, receiveBigChunk, @@ -12,9 +13,7 @@ import { getChunkOfPixel, getOffsetOfPixel, } from '../core/utils'; - -import Chunk from './ChunkRGB3D'; - +import { shardOrigin } from '../store/actions/fetch'; class ChunkLoader { store = null; @@ -91,7 +90,7 @@ class ChunkLoader { const center = [0, cx, cz]; this.store.dispatch(requestBigChunk(center)); try { - const url = `chunks/${this.canvasId}/${cx}/${cz}.bmp`; + const url = `${shardOrigin}/chunks/${this.canvasId}/${cx}/${cz}.bmp`; const response = await fetch(url); if (response.ok) { const arrayBuffer = await response.arrayBuffer(); diff --git a/src/utils/corsMiddleware.js b/src/utils/corsMiddleware.js new file mode 100644 index 0000000..d61dcce --- /dev/null +++ b/src/utils/corsMiddleware.js @@ -0,0 +1,39 @@ +/* + * set CORS Headers + */ +import { CORS_HOSTS } from '../core/config'; + +export default (req, res, next) => { + if (!CORS_HOSTS || !req.headers.origin) { + next(); + return; + } + const { origin } = req.headers; + + const host = origin.slice(origin.indexOf('//') + 2); + /* + * form .domain.tld will accept both domain.tld and x.domain.tld + */ + const isAllowed = CORS_HOSTS.some((c) => c === host + || (c.startsWith('.') && (host.endsWith(c) || host === c.slice(1)))); + + if (!isAllowed) { + next(); + return; + } + + res.set({ + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + }); + + if (req.method === 'OPTIONS') { + res.set({ + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET,POST', + }); + res.sendStatus(200); + return; + } + next(); +}; diff --git a/src/utils/ip.js b/src/utils/ip.js index 87f5f9b..6f578fd 100644 --- a/src/utils/ip.js +++ b/src/utils/ip.js @@ -50,11 +50,18 @@ function ip4NumToStr(ipNum) { * @param includeProto if we include protocol (https, http) * @return host (like pixelplanet.fun) */ -export function getHostFromRequest(req, includeProto = true) { +export function getHostFromRequest(req, includeProto = true, stripSub = false) { const { headers } = req; - const host = headers['x-forwarded-host'] + let host = headers['x-forwarded-host'] || headers.host || headers[':authority']; + if (stripSub) { + if (host.lastIndexOf('.') !== host.indexOf('.')) { + host = host.slice(host.indexOf('.')); + } else { + host = `.${host}`; + } + } if (!includeProto) { return host; } diff --git a/webpack.config.server.js b/webpack.config.server.js index 3a14ebf..1cd21c9 100644 --- a/webpack.config.server.js +++ b/webpack.config.server.js @@ -160,8 +160,8 @@ module.exports = ({ to: path.resolve('dist', 'captchaFonts'), }, { - from: path.resolve('src', 'data', 'redis', 'lua', 'placePixel.lua'), - to: path.resolve('dist', 'workers', 'placePixel.lua'), + from: path.resolve('src', 'data', 'redis', 'lua'), + to: path.resolve('dist', 'workers', 'lua'), }, ], }),