migreate chat to proper sql tables with relations

This commit is contained in:
HF 2020-11-03 23:43:51 +01:00
parent 493b0106ac
commit 482cfd3fe3
35 changed files with 4919 additions and 3052 deletions

3
API.md
View File

@ -17,8 +17,9 @@ All requests are made as JSON encoded array.
All chat messages, except the once you send with `chat` or `mcchat`, will be sent to you in the form:
```["msg", name, message, country, channelId]```
```["msg", name, message, id, country, channelId]```
channelId is an integer, channel 0 is `en` channel 1 is `int` and maybe more to come.
id is the user id
country is the [two-letter country code](https://www.nationsonline.org/oneworld/country_code_list.htm) in lowercase
### Subscribe to online user counter
```["sub", "online"]```

7022
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -42,30 +42,30 @@
"hammerjs": "^2.0.8",
"http-proxy-agent": "^4.0.1",
"image-q": "^2.1.2",
"ip-address": "^6.3.0",
"ip-address": "^6.4.0",
"isomorphic-fetch": "^2.2.1",
"js-file-download": "^0.4.12",
"keycode": "^2.1.9",
"localforage": "^1.5.0",
"localforage": "^1.9.0",
"morgan": "^1.10.0",
"multer": "^1.4.1",
"mysql2": "^2.1.0",
"node-sass": "^4.14.0",
"nodemailer": "^6.4.6",
"mysql2": "^2.2.5",
"node-sass": "^4.14.1",
"nodemailer": "^6.4.11",
"passport": "^0.4.0",
"passport-discord": "^0.1.2",
"passport-discord": "^0.1.4",
"passport-facebook": "^3.0.0",
"passport-google-oauth": "^2.0.0",
"passport-json": "^1.2.0",
"passport-reddit": "^0.2.4",
"passport-vkontakte": "^0.3.2",
"passport-vkontakte": "^0.3.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-icons": "^3.10.0",
"react-icons": "^3.11.0",
"react-modal": "^3.11.2",
"react-redux": "^7.2.0",
"react-responsive": "^8.0.3",
"react-stay-scrolled": "^7.0.0",
"react-redux": "^7.2.1",
"react-responsive": "^8.1.0",
"react-stay-scrolled": "^7.1.0",
"react-toggle-button": "^2.1.0",
"redis": "^3.0.2",
"redlock": "^4.0.0",
@ -74,69 +74,69 @@
"redux-persist": "^6.0.0",
"redux-thunk": "^2.2.0",
"sequelize": "^5.21.7",
"sharp": "^0.25.2",
"sharp": "^0.26.1",
"startaudiocontext": "^1.2.1",
"sweetalert2": "^9.10.12",
"three": "^0.116.0",
"three-trackballcontrols-ts": "^0.2.1",
"three": "^0.120.1",
"three-trackballcontrols-ts": "^0.2.2",
"url-search-params-polyfill": "^8.1.0",
"winston": "^3.2.1",
"winston-daily-rotate-file": "^4.4.2",
"ws": "^7.2.5"
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0",
"ws": "^7.3.1"
},
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/node": "^7.8.7",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3",
"@babel/plugin-proposal-do-expressions": "^7.8.3",
"@babel/plugin-proposal-export-default-from": "^7.8.3",
"@babel/plugin-proposal-export-namespace-from": "^7.8.3",
"@babel/plugin-proposal-function-bind": "^7.8.3",
"@babel/plugin-proposal-function-sent": "^7.8.3",
"@babel/plugin-proposal-json-strings": "^7.8.3",
"@babel/plugin-proposal-logical-assignment-operators": "^7.8.3",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3",
"@babel/plugin-proposal-numeric-separator": "^7.8.3",
"@babel/plugin-proposal-object-rest-spread": "^7.9.6",
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
"@babel/plugin-proposal-pipeline-operator": "^7.8.3",
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/core": "^7.11.6",
"@babel/node": "^7.10.5",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/plugin-proposal-do-expressions": "^7.10.4",
"@babel/plugin-proposal-export-default-from": "^7.10.4",
"@babel/plugin-proposal-export-namespace-from": "^7.10.4",
"@babel/plugin-proposal-function-bind": "^7.11.5",
"@babel/plugin-proposal-function-sent": "^7.10.4",
"@babel/plugin-proposal-json-strings": "^7.10.4",
"@babel/plugin-proposal-logical-assignment-operators": "^7.11.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
"@babel/plugin-proposal-numeric-separator": "^7.10.4",
"@babel/plugin-proposal-object-rest-spread": "^7.11.0",
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
"@babel/plugin-proposal-pipeline-operator": "^7.10.5",
"@babel/plugin-proposal-throw-expressions": "^7.10.4",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-import-meta": "^7.8.3",
"@babel/plugin-transform-flow-strip-types": "^7.9.0",
"@babel/plugin-transform-react-constant-elements": "^7.9.0",
"@babel/plugin-transform-react-inline-elements": "^7.9.0",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.9.6",
"@babel/preset-flow": "^7.9.0",
"@babel/preset-react": "^7.9.4",
"@babel/preset-typescript": "^7.9.0",
"clean-css": "^4.2.3",
"@babel/plugin-syntax-import-meta": "^7.10.4",
"@babel/plugin-transform-flow-strip-types": "^7.10.4",
"@babel/plugin-transform-react-constant-elements": "^7.10.4",
"@babel/plugin-transform-react-inline-elements": "^7.10.4",
"@babel/polyfill": "^7.11.5",
"@babel/preset-env": "^7.11.5",
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@babel/preset-typescript": "^7.10.4",
"assets-webpack-plugin": "^3.9.12",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0",
"babel-plugin-transform-react-pure-class-to-function": "^1.0.1",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"clean-css": "^4.2.3",
"css-loader": "^3.5.3",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.1.0",
"eslint-config-airbnb-base": "^14.1.0",
"eslint-config-airbnb": "^18.2.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-plugin-flowtype": "^4.7.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.19.0",
"flow-bin": "^0.123.0",
"generate-package-json-webpack-plugin": "^1.0.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-react": "^7.21.2",
"flow-bin": "^0.134.0",
"generate-package-json-webpack-plugin": "^1.1.2",
"json-loader": "^0.5.4",
"npm-check": "^5.9.2",
"react-hot-loader": "^4.12.21",
"react-hot-loader": "^4.13.0",
"react-svg-loader": "^3.0.3",
"rimraf": "^3.0.2",
"sass-loader": "^8.0.2",
"style-loader": "^1.2.1",
"webpack": "^4.43.0",
"webpack-bundle-analyzer": "^3.7.0",
"webpack": "^4.44.2",
"webpack-bundle-analyzer": "^3.9.0",
"webpack-dev-middleware": "^3.7.1",
"webpack-hot-middleware": "^2.18.0",
"write-file-webpack-plugin": "^4.0.2"

View File

@ -211,13 +211,6 @@ export function receiveChatMessage(
};
}
export function receiveChatHistory(data: Array): Action {
return {
type: 'RECEIVE_CHAT_HISTORY',
data,
};
}
let lastNotify = null;
export function notify(notification: string) {
return async (dispatch) => {
@ -233,7 +226,6 @@ export function notify(notification: string) {
}
let pixelTimeout = null;
export function tryPlacePixel(
i: number,
j: number,
@ -314,7 +306,7 @@ export function receivePixelReturn(
msg = 'You can not access this canvas yet. You need to place more pixels';
break;
case 8:
errorTitle = 'Oww noo';
errorTitle = 'nope';
msg = 'This pixel is protected.';
break;
case 9:
@ -498,6 +490,7 @@ export function receiveMe(
dailyRanking,
minecraftname,
canvases,
channels,
userlvl,
} = me;
return {
@ -511,6 +504,7 @@ export function receiveMe(
dailyRanking,
minecraftname,
canvases,
channels,
userlvl,
};
}
@ -575,7 +569,7 @@ export function fetchStats(): PromiseAction {
export function fetchMe(): PromiseAction {
return async (dispatch) => {
const response = await fetch('/api/me', {
const response = await fetch('api/me', {
credentials: 'include',
});
@ -586,6 +580,43 @@ export function fetchMe(): PromiseAction {
};
}
function receiveChatHistory(
cid: number,
history: Array,
) {
return {
type: 'RECEIVE_CHAT_HISTORY',
cid,
history,
};
}
function setChatFetching(fetching: boolean): Action {
return {
type: 'SET_CHAT_FETCHING',
fetching,
};
}
export function fetchChatMessages(
cid: number,
): PromiseAction {
return async (dispatch) => {
dispatch(setChatFetching(true));
const response = await fetch(`api/chathistory?cid=${cid}&limit=50`, {
credentials: 'include',
});
if (response.ok) {
setTimeout(() => { dispatch(setChatFetching(false)); }, 500);
const { history } = await response.json();
dispatch(receiveChatHistory(cid, history));
} else {
setTimeout(() => { dispatch(setChatFetching(false)); }, 5000);
}
};
}
function setCoolDown(coolDown): Action {
return {
type: 'COOLDOWN_SET',

View File

@ -67,8 +67,9 @@ export type Action =
channel: number,
isPing: boolean,
}
| { type: 'RECEIVE_CHAT_HISTORY', data: Array }
| { type: 'RECEIVE_CHAT_HISTORY', cid: number, history: Array }
| { type: 'SET_CHAT_CHANNEL', channelId: number }
| { type: 'SET_CHAT_FETCHING', fetching: boolean }
| { type: 'RECEIVE_ME',
name: string,
waitSeconds: number,
@ -80,6 +81,7 @@ export type Action =
dailyRanking: number,
minecraftname: string,
canvases: Object,
channels: Object,
userlvl: number,
}
| { type: 'RECEIVE_STATS', totalRanking: Object, totalDailyRanking: Object }

View File

@ -16,7 +16,6 @@ import {
receiveOnline,
receiveCoolDown,
receiveChatMessage,
receiveChatHistory,
receivePixelReturn,
setMobile,
tryPlacePixel,
@ -57,9 +56,6 @@ function init() {
const isPing = (nameRegExp && text.match(nameRegExp));
store.dispatch(receiveChatMessage(name, text, country, channelId, isPing));
});
ProtocolClient.on('chatHistory', (data) => {
store.dispatch(receiveChatHistory(data));
});
ProtocolClient.on('changedMe', () => {
store.dispatch(fetchMe());
});

View File

@ -423,7 +423,7 @@ function Admintools({
))}
</select>
<br />
<textarea rows="10" cols="100" id="iparea" /><br />
<textarea rows="10" cols="17" id="iparea" /><br />
<button
type="button"
onClick={() => {

View File

@ -11,9 +11,13 @@ import { connect } from 'react-redux';
import type { State } from '../reducers';
import ChatMessage from './ChatMessage';
import { MAX_CHAT_MESSAGES } from '../core/constants';
import { showUserAreaModal, setChatChannel } from '../actions';
import { MAX_CHAT_MESSAGES, CHAT_CHANNELS } from '../core/constants';
import {
showUserAreaModal,
setChatChannel,
fetchChatMessages,
} from '../actions';
import ProtocolClient from '../socket/ProtocolClient';
import { saveSelection, restoreSelection } from '../utils/storeSelection';
import splitChatMessage from '../core/chatMessageFilter';
@ -23,23 +27,30 @@ function escapeRegExp(string) {
}
const Chat = ({
chatMessages,
channels,
messages,
chatChannel,
ownName,
open,
setChannel,
fetchMessages,
fetching,
}) => {
const listRef = useRef();
const inputRef = useRef();
const [inputMessage, setInputMessage] = useState('');
const [selection, setSelection] = useState(null);
const [nameRegExp, setNameRegExp] = useState(null);
const { stayScrolled } = useStayScrolled(listRef, {
initialScroll: Infinity,
inaccuracy: 10,
});
const channelMessages = chatMessages[chatChannel];
const channelMessages = messages[chatChannel] || [];
if (!messages[chatChannel] && !fetching) {
fetchMessages(chatChannel);
}
useLayoutEffect(() => {
stayScrolled();
@ -77,6 +88,22 @@ const Chat = ({
setInputMessage('');
}
/*
* if selected channel isn't in channel list anymore
* for whatever reason (left faction etc.)
* set channel to first available one
*/
let i = 0;
while (i < channels.length) {
// eslint-disable-next-line eqeqeq
if (channels[i][0] == chatChannel) {
break;
}
i += 1;
}
if (i && i === channels.length) {
setChannel(channels[0][0]);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@ -87,6 +114,17 @@ const Chat = ({
onMouseUp={() => { setSelection(saveSelection); }}
role="presentation"
>
{
(!channelMessages.length)
&& (
<ChatMessage
name="info"
msgArray={splitChatMessage('Start chatting here', nameRegExp)}
country="xx"
insertText={(txt) => padToInputMessage(txt)}
/>
)
}
{
channelMessages.map((message) => (
<ChatMessage
@ -121,11 +159,20 @@ const Chat = ({
</button>
<select
style={{ flexGrow: 0 }}
onChange={(evt) => setChannel(evt.target.selectedIndex)}
onChange={(evt) => {
const sel = evt.target;
setChannel(sel.options[sel.selectedIndex].value);
}}
>
{
CHAT_CHANNELS.map((ch) => (
<option selected={ch === chatChannel}>{ch}</option>
channels.map((ch) => (
<option
// eslint-disable-next-line eqeqeq
selected={ch[0] == chatChannel}
value={ch[0]}
>
{ch[1]}
</option>
))
}
</select>
@ -147,9 +194,16 @@ const Chat = ({
};
function mapStateToProps(state: State) {
const { chatMessages, name } = state.user;
const { name } = state.user;
const { chatChannel } = state.gui;
return { chatMessages, chatChannel, ownName: name };
const { channels, messages, fetching } = state.chat;
return {
channels,
messages,
fetching,
chatChannel,
ownName: name,
};
}
function mapDispatchToProps(dispatch) {
@ -160,6 +214,9 @@ function mapDispatchToProps(dispatch) {
setChannel(channelId) {
dispatch(setChatChannel(channelId));
},
fetchMessages(channelId) {
dispatch(fetchChatMessages(channelId));
},
};
}

View File

@ -0,0 +1,113 @@
/*
* Buffer for chatMessages for the server
* it just buffers the msot recent 200 messages for each channel
*
* @flow
*/
import logger from './logger';
import { RegUser, Message } from '../data/models';
const MAX_BUFFER_TIME = 120000;
class ChatMessageBuffer {
constructor() {
this.buffer = new Map();
this.timestamps = new Map();
this.cleanBuffer = this.cleanBuffer.bind(this);
this.cleanLoop = setInterval(this.cleanBuffer, 3 * 60 * 1000);
}
async getMessages(cid, limit = 30) {
if (limit > 200) {
return ChatMessageBuffer.getMessagesFromDatabase(cid, limit);
}
let messages = this.buffer.get(cid);
if (!messages) {
messages = await ChatMessageBuffer.getMessagesFromDatabase(cid);
this.buffer.set(cid, messages);
}
this.timestamps.set(cid, Date.now());
return messages.slice(-limit);
}
cleanBuffer() {
const curTime = Date.now();
const toDelete = [];
this.timestamps.forEach((cid, timestamp) => {
if (curTime > timestamp + MAX_BUFFER_TIME) {
toDelete.push(cid);
}
});
toDelete.forEach((cid) => {
this.buffer.delete(cid);
this.timestamps.delete(cid);
});
logger.info(
`Cleaned ${toDelete.length} channels from chat message buffer`,
);
}
async addMessage(
name,
message,
cid,
uid,
flag,
) {
Message.create({
cid,
uid,
message,
});
const messages = this.buffer.get(cid);
if (messages) {
messages.push([
name,
message,
flag,
]);
}
}
static async getMessagesFromDatabase(cid, limit = 200) {
const messagesModel = await Message.findAll({
include: [
{
model: RegUser,
as: 'user',
foreignKey: 'uid',
attributes: [
'name',
'flag',
],
},
],
attributes: [
'message',
],
where: { cid },
limit,
order: ['createdAt'],
raw: true,
});
const messages = [];
messagesModel.forEach((msg) => {
const {
message,
'user.name': name,
'user.flag': flag,
} = msg;
messages.push([
name,
message,
flag,
]);
});
return messages;
}
}
export default ChatMessageBuffer;

View File

@ -1,25 +1,19 @@
/* @flow */
import Sequelize from 'sequelize';
import logger from './logger';
import redis from '../data/redis';
import User from '../data/models/User';
import webSockets from '../socket/websockets';
import { Channel } from '../data/models';
import ChatMessageBuffer from './ChatMessageBuffer';
import { CHAT_CHANNELS } from './constants';
export class ChatProvider {
/*
* TODO:
* history really be saved in redis
*/
history: Array;
constructor() {
this.history = [];
for (let i = 0; i < CHAT_CHANNELS.length; i += 1) {
this.history.push([]);
}
this.defaultChannels = [];
this.defaultChannelIds = [];
this.caseCheck = /^[A-Z !.]*$/;
this.cyrillic = new RegExp('[\u0436-\u043B]');
this.filters = [
@ -47,27 +41,69 @@ export class ChatProvider {
},
];
this.mutedCountries = [];
this.chatMessageBuffer = new ChatMessageBuffer();
}
addMessage(name, message, country, channelId = 0) {
const channelHistory = this.history[channelId];
if (channelHistory.length > 20) {
channelHistory.shift();
async initialize() {
// find or create default channels
this.defaultChannels.length = 0;
this.defaultChannelIds.length = 0;
for (let i = 0; i < CHAT_CHANNELS.length; i += 1) {
const { name } = CHAT_CHANNELS[i];
// eslint-disable-next-line no-await-in-loop
const channel = await Channel.findOrCreate({
attributes: [
'id',
'lastMessage',
],
where: { name },
defaults: {
name,
lastMessage: Sequelize.literal('CURRENT_TIMESTAMP'),
},
raw: true,
});
const { id } = channel[0];
this.defaultChannels.push([
id,
name,
]);
this.defaultChannelIds.push(id);
}
channelHistory.push([name, message, country]);
}
userHasChannelAccess(user, cid, write = false) {
if (this.defaultChannelIds.includes(cid)) {
if (!write || user.regUser) {
return true;
}
} else if (user.regUser && user.channelIds.includes(cid)) {
return true;
}
return false;
}
getHistory(cid, limit = 30) {
return this.chatMessageBuffer.getMessages(cid, limit);
}
async sendMessage(user, message, channelId: number = 0) {
const name = (user.regUser) ? user.regUser.name : null;
const country = user.country || 'xx';
const { id } = user;
const name = user.getName();
if (!name || !id) {
// eslint-disable-next-line max-len
return 'Couldn\'t send your message, pls log out and back in again.';
}
if (!this.userHasChannelAccess(user, channelId)) {
return 'You don\'t have access to this channel';
}
const country = user.regUser.flag || 'xx';
let displayCountry = (name.endsWith('berg') || name.endsWith('stein'))
? 'il'
: country;
if (!name) {
// eslint-disable-next-line max-len
return 'Couldn\'t send your message, pls log out and back in again.';
}
if (!user.regUser.verified) {
return 'Your mail has to be verified in order to chat';
}
@ -166,23 +202,49 @@ export class ChatProvider {
return 'Your country is temporary muted from chat';
}
this.addMessage(name, message, displayCountry, channelId);
webSockets.broadcastChatMessage(name, message, displayCountry, channelId);
this.chatMessageBuffer.addMessage(
name,
message,
channelId,
id,
displayCountry,
);
webSockets.broadcastChatMessage(
name,
message,
channelId,
id,
displayCountry,
);
return null;
}
broadcastChatMessage(
name,
message,
channelId: number = 1,
id = -1,
country: string = 'xx',
channelId: number = 0,
sendapi: boolean = true,
) {
if (message.length > 250) {
return;
}
this.addMessage(name, message, country, channelId);
webSockets.broadcastChatMessage(name, message, country, channelId, sendapi);
this.chatMessageBuffer.addMessage(
name,
message,
channelId,
id,
country,
);
webSockets.broadcastChatMessage(
name,
message,
channelId,
id,
country,
sendapi,
);
}
static automute(name, channelId = 0) {

View File

@ -89,6 +89,13 @@ export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const MONTH = 30 * DAY;
// available Chat Channels
export const CHAT_CHANNELS = ['en', 'int'];
// available public Chat Channels
export const CHAT_CHANNELS = [
{
name: 'en',
}, {
name: 'int',
},
];
export const MAX_CHAT_MESSAGES = 100;

View File

@ -7,6 +7,7 @@
*/
// eslint-disable-next-line import/no-unresolved
import canvases from './canvases.json';
import chatProvider from './ChatProvider';
export default async function getMe(user) {
@ -29,7 +30,10 @@ export default async function getMe(user) {
delete userdata.mailVerified;
delete userdata.mcVerified;
const channels = [...chatProvider.defaultChannels];
userdata.canvases = canvases;
userdata.channels = channels;
return userdata;
}

View File

@ -15,7 +15,7 @@ import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth';
import logger from './logger';
import { sanitizeName } from '../utils/validation';
import { User, RegUser } from '../data/models';
import { User, RegUser, Channel } from '../data/models';
import { auth } from './config';
import { compareToHash } from '../utils/hash';
import { getIPFromRequest } from '../utils/ip';
@ -28,13 +28,22 @@ passport.serializeUser((user, done) => {
passport.deserializeUser(async (req, id, done) => {
const user = new User(id, getIPFromRequest(req));
if (id) {
RegUser.findOne({ where: { id } }).then((reguser) => {
RegUser.findByPk(id, {
include: {
model: Channel,
as: 'channel',
},
}).then((reguser) => {
if (reguser) {
user.regUser = reguser;
user.id = id;
for (let i = 0; i < reguser.channel.length; i += 1) {
user.channelIds.push(reguser.channel[i].id);
}
} else {
user.id = null;
}
done(null, user);
});
} else {

View File

@ -0,0 +1,33 @@
/*
*
* Database layout for Chat Channels
*
* @flow
*
*/
import DataType from 'sequelize';
import Model from '../sequelize';
const Channel = Model.define('Channel', {
// Channel ID
id: {
type: DataType.INTEGER.UNSIGNED,
autoIncrement: true,
primaryKey: true,
},
name: {
type: `${DataType.CHAR(32)} CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci`,
allowNull: false,
},
lastMessage: {
type: DataType.DATE,
allowNull: false,
},
}, {
updatedAt: false,
});
export default Channel;

View File

@ -0,0 +1,40 @@
/*
*
* Database layout for Chat Message History
*
* @flow
*
*/
import DataType from 'sequelize';
import Model from '../sequelize';
import Channel from './Channel';
import RegUser from './RegUser';
const Message = Model.define('Message', {
// Message ID
id: {
type: DataType.INTEGER.UNSIGNED,
autoIncrement: true,
primaryKey: true,
},
message: {
type: `${DataType.CHAR(200)} CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci`,
allowNull: false,
},
}, {
updatedAt: false,
});
Message.belongsTo(Channel, {
as: 'channel',
foreignKey: 'cid',
});
Message.belongsTo(RegUser, {
as: 'user',
foreignKey: 'uid',
});
export default Message;

View File

@ -25,7 +25,7 @@ const RegUser = Model.define('User', {
},
name: {
type: DataType.CHAR(32),
type: `${DataType.CHAR(32)} CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci`,
allowNull: false,
},
@ -91,6 +91,13 @@ const RegUser = Model.define('User', {
allowNull: true,
},
// flag == country code
flag: {
type: DataType.CHAR(2),
defaultValue: 'xx',
allowNull: false,
},
lastLogIn: {
type: DataType.DATE,
allowNull: true,

View File

@ -25,12 +25,14 @@ class User {
regUser: Object;
constructor(id: string = null, ip: string = '127.0.0.1') {
// id should stay null if unregistered, and user email if registered
// id should stay null if unregistered
this.id = id;
this.ip = ip;
this.ipSub = getIPv6Subnet(ip);
this.wait = null;
// following gets populated by passport
this.regUser = null;
this.channelIds = [];
}
static async name2Id(name: string) {
@ -48,6 +50,10 @@ class User {
}
}
getName() {
return (this.regUser) ? this.regUser.name : null;
}
async setWait(coolDown: number, canvasId: number): Promise<boolean> {
if (!coolDown) return false;
this.wait = Date.now() + coolDown;
@ -115,6 +121,14 @@ class User {
}
}
async setCountry(country) {
if (this.regUser && this.regUser.flag !== country) {
this.regUser.update({
flag: country,
});
}
}
async updateLogInTimestamp(): Promise<boolean> {
if (!this.regUser) return false;
try {

View File

@ -0,0 +1,23 @@
/*
*
* Junction table for User -> Channels
* A channel can be anything,
* Group, Public Chat, DM, etc.
*
* @flow
*
*/
import DataType from 'sequelize';
import Model from '../sequelize';
const UserChannel = Model.define('UserChannel', {
lastRead: {
type: DataType.DATE,
allowNull: true,
},
}, {
timestamps: false,
});
export default UserChannel;

View File

@ -5,13 +5,32 @@ import Blacklist from './Blacklist';
import Whitelist from './Whitelist';
import User from './User';
import RegUser from './RegUser';
import Channel from './Channel';
import UserChannel from './UserChannel';
import Message from './Message';
RegUser.belongsToMany(Channel, {
as: 'channel',
through: UserChannel,
});
Channel.belongsToMany(RegUser, {
as: 'user',
through: UserChannel,
});
function sync(...args) {
return sequelize.sync(...args);
}
export default { sync };
/*
* makes sure that minimum required rows are present
*
*/
function validateTables() {
}
export default { sync, validateTables };
export {
Whitelist, Blacklist, User, RegUser,
Whitelist, Blacklist, User, RegUser, Channel, UserChannel, Message,
};

79
src/reducers/chat.js Normal file
View File

@ -0,0 +1,79 @@
/* @flow */
import { MAX_CHAT_MESSAGES } from '../core/constants';
import type { Action } from '../actions/types';
export type ChatState = {
// [[cid, name], [cid2, name2],...]
channels: Array,
// { cid: [message1,message2,message2,...]}
messages: Object,
// if currently fetching messages
fetching: boolean,
}
const initialState: ChatState = {
channels: [],
messages: {},
fetching: false,
};
export default function chat(
state: ChatState = initialState,
action: Action,
): ChatState {
switch (action.type) {
case 'RECEIVE_ME': {
return {
...state,
channels: action.channels,
};
}
case 'SET_CHAT_FETCHING': {
const { fetching } = action;
return {
...state,
fetching,
};
}
case 'RECEIVE_CHAT_MESSAGE': {
const {
name, text, country, channel,
} = action;
if (!state.messages[channel]) {
return state;
}
const messages = {
...state.messages,
[channel]: [
...state.messages[channel],
[name, text, country],
],
};
if (messages[channel].length > MAX_CHAT_MESSAGES) {
messages[channel].shift();
}
return {
...state,
messages,
};
}
case 'RECEIVE_CHAT_HISTORY': {
const { cid, history } = action;
return {
...state,
messages: {
...state.messages,
[cid]: history,
},
};
}
default:
return state;
}
}

View File

@ -30,7 +30,7 @@ const initialState: GUIState = {
compactPalette: false,
paletteOpen: true,
menuOpen: false,
chatChannel: 0,
chatChannel: 1,
style: 'default',
};

View File

@ -7,12 +7,14 @@ import canvas from './canvas';
import gui from './gui';
import modal from './modal';
import user from './user';
import chat from './chat';
import type { AudioState } from './audio';
import type { CanvasState } from './canvas';
import type { GUIState } from './gui';
import type { ModalState } from './modal';
import type { UserState } from './user';
import type { ChatState } from './chat';
export type State = {
audio: AudioState,
@ -20,12 +22,13 @@ export type State = {
gui: GUIState,
modal: ModalState,
user: UserState,
chat: ChatState,
};
const config = {
key: 'primary',
storage: localForage,
blacklist: ['user', 'canvas', 'modal'],
blacklist: ['user', 'canvas', 'modal', 'chat'],
};
export default persistCombineReducers(config, {
@ -34,4 +37,5 @@ export default persistCombineReducers(config, {
gui,
modal,
user,
chat,
});

View File

@ -1,7 +1,5 @@
/* @flow */
import { MAX_CHAT_MESSAGES } from '../core/constants';
import type { Action } from '../actions/types';
@ -24,8 +22,6 @@ export type UserState = {
// global stats
totalRanking: Object,
totalDailyRanking: Object,
// chat
chatMessages: Array,
// minecraft
minecraftname: string,
// if user is using touchscreen
@ -48,10 +44,6 @@ const initialState: UserState = {
mailreg: false,
totalRanking: {},
totalDailyRanking: {},
chatMessages: [
[['info', 'Welcome to the PixelPlanet Chat', 'il']],
[['info', 'Welcome to the PixelPlanet Chat', 'il']],
],
minecraftname: null,
isOnMobile: false,
notification: null,
@ -138,33 +130,6 @@ export default function user(
};
}
case 'RECEIVE_CHAT_MESSAGE': {
const {
name, text, country, channel,
} = action;
const chatMessages = state.chatMessages.slice();
let channelMessages = chatMessages[channel];
if (channelMessages.length > MAX_CHAT_MESSAGES) {
channelMessages = channelMessages.slice(-50);
}
channelMessages = channelMessages.concat([
[name, text, country],
]);
chatMessages[channel] = channelMessages;
return {
...state,
chatMessages,
};
}
case 'RECEIVE_CHAT_HISTORY': {
const { data: chatMessages } = action;
return {
...state,
chatMessages,
};
}
case 'RECEIVE_ME': {
const {
name,

View File

@ -0,0 +1,57 @@
/*
*
* returns chat messages of given channel
*
* @flow
*/
import type { Request, Response } from 'express';
import chatProvider from '../../core/ChatProvider';
async function chatHistory(req: Request, res: Response) {
let { cid, limit } = req.query;
if (!cid || !limit) {
res.status(400);
res.json({
errors: ['cid or limit not defined'],
});
return;
}
cid = parseInt(cid, 10);
limit = parseInt(limit, 10);
if (Number.isNaN(cid) || Number.isNaN(limit)
|| limit <= 0 || limit > 300) {
res.status(400);
res.json({
errors: ['cid or limit not a valid value'],
});
return;
}
const { user } = req;
if (!chatProvider.userHasChannelAccess(user, cid, false)) {
res.status(401);
res.json({
errors: ['You don\'t have access to this channel'],
});
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;

View File

@ -17,6 +17,7 @@ import captcha from './captcha';
import auth from './auth';
import ranking from './ranking';
import history from './history';
import chatHistory from './chathistory';
const router = express.Router();
@ -77,6 +78,8 @@ router.get('/me', me);
router.post('/mctp', mctp);
router.get('/chathistory', chatHistory);
router.use('/auth', auth(passport));
export default router;

View File

@ -18,7 +18,7 @@ const CANVAS_MIN_XY = -CANVAS_MAX_XY;
export default async (req: Request, res: Response) => {
const { user } = req;
if (!user) {
if (!user.regUser) {
res.status(401);
res.json({
success: false,

View File

@ -86,8 +86,9 @@ class APISocketServer extends WebSocketEvents {
broadcastChatMessage(
name,
msg,
country,
channelId,
id,
country,
sendapi,
ws = null,
) {
@ -249,14 +250,44 @@ class APISocketServer extends WebSocketEvents {
const chatname = (user.id)
? `[MC] ${user.regUser.name}`
: `[MC] ${minecraftname}`;
chatProvider.broadcastChatMessage(chatname, msg, 'xx', 0, false);
this.broadcastChatMessage(chatname, msg, 'xx', 0, true, ws);
chatProvider.broadcastChatMessage(
chatname,
msg,
0,
-1,
'xx',
false,
);
this.broadcastChatMessage(
chatname,
msg,
0,
-1,
'xx',
true,
ws,
);
return;
}
if (command === 'chat') {
const [name, msg, country, channelId] = packet;
chatProvider.broadcastChatMessage(name, msg, country, channelId, false);
this.broadcastChatMessage(name, msg, country, channelId, true, ws);
chatProvider.broadcastChatMessage(
name,
msg,
channelId,
-1,
country,
false,
);
this.broadcastChatMessage(
name,
msg,
channelId,
-1,
country,
true,
ws,
);
return;
}
if (command === 'linkacc') {

View File

@ -16,7 +16,6 @@ import RegisterCanvas from './packets/RegisterCanvas';
import RegisterChunk from './packets/RegisterChunk';
import RegisterMultipleChunks from './packets/RegisterMultipleChunks';
import DeRegisterChunk from './packets/DeRegisterChunk';
import RequestChatHistory from './packets/RequestChatHistory';
import ChangedMe from './packets/ChangedMe';
const chunks = [];
@ -26,6 +25,7 @@ class ProtocolClient extends EventEmitter {
ws: WebSocket;
name: string;
canvasId: number;
channelId: number;
timeConnected: number;
isConnected: number;
isConnecting: boolean;
@ -39,6 +39,7 @@ class ProtocolClient extends EventEmitter {
this.ws = null;
this.name = null;
this.canvasId = '0';
this.channelId = 0;
this.msgQueue = [];
}
@ -82,7 +83,6 @@ class ProtocolClient extends EventEmitter {
this.isConnecting = false;
this.isConnected = true;
this.emit('open', {});
this.requestChatHistory();
if (this.canvasId !== null) {
this.ws.send(RegisterCanvas.dehydrate(this.canvasId));
}
@ -143,11 +143,6 @@ class ProtocolClient extends EventEmitter {
this.sendWhenReady(buffer);
}
requestChatHistory() {
const buffer = RequestChatHistory.dehydrate();
if (this.isConnected) this.ws.send(buffer);
}
sendChatMessage(message, channelId) {
if (this.isConnected) {
this.ws.send(JSON.stringify([message, channelId]));
@ -174,11 +169,6 @@ class ProtocolClient extends EventEmitter {
const data = JSON.parse(message);
if (Array.isArray(data)) {
if (Array.isArray(data[0])) {
// Array in Array: Chat History
this.emit('chatHistory', data);
return;
}
if (data.length === 4) {
// Ordinary array: Chat message
const [name, text, country, channelId] = data;

View File

@ -16,7 +16,6 @@ import RegisterChunk from './packets/RegisterChunk';
import RegisterMultipleChunks from './packets/RegisterMultipleChunks';
import DeRegisterChunk from './packets/DeRegisterChunk';
import DeRegisterMultipleChunks from './packets/DeRegisterMultipleChunks';
import RequestChatHistory from './packets/RequestChatHistory';
import ChangedMe from './packets/ChangedMe';
import OnlineCounter from './packets/OnlineCounter';
@ -85,7 +84,7 @@ class SocketServer extends WebSocketEvents {
ws.on('pong', heartbeat);
const user = await authenticateClient(req);
ws.user = user;
ws.name = (user.regUser) ? user.regUser.name : null;
ws.name = user.getName();
ws.rateLimiter = new RateLimiter(20, 15, true);
cheapDetector(user.ip);
@ -166,13 +165,16 @@ class SocketServer extends WebSocketEvents {
broadcastChatMessage(
name: string,
message: string,
channelId: number,
id: number,
country: string,
channelId: number = 0,
) {
const text = JSON.stringify([name, message, country, channelId]);
this.wss.clients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(text);
if (chatProvider.userHasChannelAccess(ws.user, channelId)) {
ws.send(text);
}
}
});
}
@ -199,7 +201,9 @@ class SocketServer extends WebSocketEvents {
// eslint-disable-next-line no-underscore-dangle
client._socket.write(buffer);
} catch (error) {
logger.error('(!) Catched error on write socket:', error);
logger.error(
`WebSocket broadcast pixelbuffer error: ${error.message}`,
);
}
});
}
@ -313,8 +317,10 @@ class SocketServer extends WebSocketEvents {
} else {
logger.info('Got empty message or message from unidentified ws');
}
} catch {
logger.info('Got invalid ws text message');
} catch (error) {
logger.error('Got invalid ws text message');
logger.error(error.message);
logger.error(error.stack);
}
}
@ -397,11 +403,6 @@ class SocketServer extends WebSocketEvents {
}
break;
}
case RequestChatHistory.OP_CODE: {
const history = JSON.stringify(chatProvider.history);
ws.send(history);
break;
}
default:
break;
}

View File

@ -1,13 +0,0 @@
/* @flow */
const OP_CODE = 0xA5;
export default {
OP_CODE,
dehydrate(): ArrayBuffer {
const buffer = new ArrayBuffer(1);
const view = new DataView(buffer);
view.setInt8(0, OP_CODE);
return buffer;
},
};

View File

@ -22,9 +22,10 @@ function authenticateClient(req) {
((resolve) => {
router(req, {}, async () => {
const country = req.headers['cf-ipcountry'] || 'xx';
const countryCode = country.toLowerCase();
const user = (req.user) ? req.user
: new User(null, getIPFromRequest(req));
user.country = country.toLowerCase();
user.setCountry(countryCode);
resolve(user);
});
}),

View File

@ -71,8 +71,9 @@ class WebSockets {
broadcastChatMessage(
name: string,
message: string,
country: string,
channelId: number = 0,
channelId: number = 1,
id: number = -1,
country: string = 'xx',
sendapi: boolean = true,
) {
country = country || 'xx';
@ -80,8 +81,9 @@ class WebSockets {
(listener) => listener.broadcastChatMessage(
name,
message,
country,
channelId,
id,
country,
sendapi,
),
);

View File

@ -223,7 +223,7 @@ class ChunkLoader {
this.store.dispatch(requestBigChunk(center));
chunkRGB.isBasechunk = true;
try {
const url = `/chunks/${this.canvasId}/${cx}/${cy}.bmp`;
const url = `chunks/${this.canvasId}/${cx}/${cy}.bmp`;
const response = await fetch(url);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
@ -247,7 +247,7 @@ class ChunkLoader {
const center = [zoom, cx, cy];
this.store.dispatch(requestBigChunk(center));
try {
const url = `/tiles/${this.canvasId}/${zoom}/${cx}/${cy}.png`;
const url = `tiles/${this.canvasId}/${zoom}/${cx}/${cy}.png`;
const img = await loadImage(url);
chunkRGB.fromImage(img);
this.store.dispatch(receiveBigChunk(center));

View File

@ -99,7 +99,7 @@ class ChunkLoader {
const center = [0, cx, cz];
this.store.dispatch(requestBigChunk(center));
try {
const url = `/chunks/${this.canvasId}/${cx}/${cz}.bmp`;
const url = `chunks/${this.canvasId}/${cx}/${cz}.bmp`;
const response = await fetch(url);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();

View File

@ -14,6 +14,7 @@ import assets from './assets.json'; // eslint-disable-line import/no-unresolved
import logger from './core/logger';
import rankings from './core/ranking';
import models from './data/models';
import chatProvider from './core/ChatProvider';
import SocketServer from './socket/SocketServer';
import APISocketServer from './socket/APISocketServer';
@ -192,10 +193,14 @@ app.get('/', async (req, res) => {
//
// ip config
// -----------------------------------------------------------------------------
const promise = models.sync().catch((err) => logger.error(err.stack));
// use this if models changed:
const promise = models.sync({ alter: { drop: false } })
// const promise = models.sync()
.catch((err) => logger.error(err.stack));
promise.then(() => {
server.listen(PORT, () => {
rankings.updateRanking();
chatProvider.initialize();
const address = server.address();
logger.log('info', `web is running at http://localhost:${address.port}/`);
});