feat: limiting, with message impl

This commit is contained in:
cha0s 2020-07-22 03:08:00 -05:00
parent c23570c25d
commit 0ca11a0ec4
10 changed files with 98 additions and 11 deletions

View File

@ -45,6 +45,7 @@
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"passport-reddit": "0.2.4", "passport-reddit": "0.2.4",
"prop-types": "^15", "prop-types": "^15",
"rate-limiter-flexible": "2.1.9",
"react": "16.8.6", "react": "16.8.6",
"react-dom": "16.8.6", "react-dom": "16.8.6",
"react-hot-loader": "^4.12.21", "react-hot-loader": "^4.12.21",

View File

@ -9,7 +9,15 @@ import {useSelector} from 'react-redux';
import {usernameSelector} from '~/common/state/usernames'; import {usernameSelector} from '~/common/state/usernames';
export default function ChatMessage(props) { export default function ChatMessage(props) {
const {message: {owner, message, timestamp}, isShort} = props; const {
message: {
owner,
message,
timestamp,
rejected,
},
isShort,
} = props;
const username = useSelector((state) => usernameSelector(state, owner)); const username = useSelector((state) => usernameSelector(state, owner));
const dtf = new Intl.DateTimeFormat(undefined, { const dtf = new Intl.DateTimeFormat(undefined, {
hour: '2-digit', hour: '2-digit',
@ -26,12 +34,12 @@ export default function ChatMessage(props) {
); );
return ( return (
<div <div
className={classnames('chat--message', {short: isShort})} className={classnames('chat--message', {rejected, short: isShort, system: -1 === owner})}
> >
{ {
!isShort && ( !isShort && (
<header> <header>
<div className="chat--messageOwner">{username}</div> <div className="chat--messageOwner">{-1 === owner ? '! System !' : username}</div>
{$messageTime} {$messageTime}
</header> </header>
) )
@ -50,6 +58,7 @@ ChatMessage.propTypes = {
isShort: PropTypes.bool.isRequired, isShort: PropTypes.bool.isRequired,
message: PropTypes.shape({ message: PropTypes.shape({
message: PropTypes.string, message: PropTypes.string,
rejected: PropTypes.bool,
timestamp: PropTypes.number, timestamp: PropTypes.number,
owner: PropTypes.number, owner: PropTypes.number,
}).isRequired, }).isRequired,

View File

@ -1,6 +1,14 @@
@import '~/client/scss/colors.scss';
.chat--message { .chat--message {
margin-top: 0.75rem; margin-top: 0.75rem;
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
&.rejected {
background-color: rgba(200, 0, 0, 0.2);
}
&.system {
background-color: rgba(200, 0, 0, 0.2);
}
} }
.chat--message, .chat--message * { .chat--message, .chat--message * {
@ -43,6 +51,9 @@ header {
font-family: Caladea, 'Times New Roman', Times, serif; font-family: Caladea, 'Times New Roman', Times, serif;
font-weight: bold; font-weight: bold;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
.system & {
color: lighten($color-active, 30%);
}
} }
.chat--messageTime { .chat--messageTime {
@ -57,6 +68,10 @@ header {
.chat--messageText { .chat--messageText {
font-size: 0.9rem; font-size: 0.9rem;
.rejected & {
color: red;
text-decoration: line-through;
}
} }
.chat--messageMarkdown * { .chat--messageMarkdown * {

View File

@ -1,3 +1,5 @@
import {v4 as uuidv4} from 'uuid';
import AddFavorite from '../../common/packets/add-favorite.packet'; import AddFavorite from '../../common/packets/add-favorite.packet';
import AddFriend from '~/common/packets/add-friend.packet'; import AddFriend from '~/common/packets/add-friend.packet';
import Block from '~/common/packets/block.packet'; import Block from '~/common/packets/block.packet';
@ -13,6 +15,7 @@ import {
join, join,
joined, joined,
leave, leave,
rejectMessage,
submitJoin, submitJoin,
submitLeave, submitLeave,
submitMessage, submitMessage,
@ -68,7 +71,7 @@ const effects = {
const state = getState(); const state = getState();
const userId = idSelector(state); const userId = idSelector(state);
const hasFriendship = !!payload.addeeId; const hasFriendship = !!payload.addeeId;
socket.send(new AddFriend({nameOrStatus: payload.nameOrStatus}), ({error, id}) => { socket.send(new AddFriend({nameOrStatus: payload.nameOrStatus}), (error, id) => {
if (error) { if (error) {
return; return;
} }
@ -88,7 +91,7 @@ const effects = {
}, },
[submitJoin]: ({dispatch}, {payload}) => { [submitJoin]: ({dispatch}, {payload}) => {
const {channel} = payload; const {channel} = payload;
socket.send(new Join(payload), ({messages, users}) => { socket.send(new Join(payload), (error, {messages, users}) => {
dispatch(join({channel, messages, users})); dispatch(join({channel, messages, users}));
}); });
}, },
@ -98,7 +101,27 @@ const effects = {
}, },
[submitMessage]: ({dispatch}, {payload}) => { [submitMessage]: ({dispatch}, {payload}) => {
dispatch(addMessage(payload)); dispatch(addMessage(payload));
socket.send(new Message(payload), ([timestamp, current]) => { socket.send(new Message(payload), (error, result) => {
if (error) {
switch (error.code) {
case 429: {
dispatch(rejectMessage(payload.uuid));
dispatch(addMessage({
...payload,
message: [
'You are sending too many messages.',
`Try again in ${error.ttr} second${1 === error.ttr ? '' : 's'}.`,
].join(' '),
owner: -1,
uuid: uuidv4(),
}));
break;
}
default:
}
return;
}
const [timestamp, current] = result;
dispatch(confirmMessage({current, previous: payload.uuid, timestamp})); dispatch(confirmMessage({current, previous: payload.uuid, timestamp}));
}); });
}, },

View File

@ -70,6 +70,9 @@ const slice = createSlice({
const {users} = channels[channel]; const {users} = channels[channel];
users.splice(users.indexOf(id), 1); users.splice(users.indexOf(id), 1);
}, },
rejectMessage: ({messages}, {payload: uuid}) => {
messages[uuid].rejected = true;
},
removeMessage: (state, {payload: {channel, uuid}}) => { removeMessage: (state, {payload: {channel, uuid}}) => {
delete state.messages[uuid]; delete state.messages[uuid];
const {messages} = state.channels[channel]; const {messages} = state.channels[channel];
@ -89,6 +92,7 @@ export const {
joined, joined,
leave, leave,
left, left,
rejectMessage,
removeMessage, removeMessage,
submitJoin, submitJoin,
submitLeave, submitLeave,

7
src/server/limiter.js Normal file
View File

@ -0,0 +1,7 @@
import {RateLimiterRedis} from 'rate-limiter-flexible';
import createRedisClient from './redis';
const storeClient = createRedisClient();
export default (options) => new RateLimiterRedis({...options, storeClient});

View File

@ -33,6 +33,6 @@ export default {
nameOrStatus: friendship.status, nameOrStatus: friendship.status,
})); }));
}); });
fn({id: addeeId}); fn(addeeId);
}, },
}; };

View File

@ -7,6 +7,7 @@ import {allModels} from '~/server/models/registrar';
export default { export default {
Packet: Message, Packet: Message,
limiter: {points: 10, duration: 15},
validator: () => true, validator: () => true,
responder: async (packet, socket, fn) => { responder: async (packet, socket, fn) => {
const {req} = socket; const {req} = socket;

View File

@ -6,6 +6,7 @@ import {joinChannel, parseChannel} from '~/common/channel';
import {channelsToHydrate} from '~/server/entry'; import {channelsToHydrate} from '~/server/entry';
import createLimiter from './limiter';
import * as PacketHandlers from './packet'; import * as PacketHandlers from './packet';
import {userJoin} from './packet/join'; import {userJoin} from './packet/join';
import {userLeave} from './packet/leave'; import {userLeave} from './packet/leave';
@ -20,8 +21,14 @@ const packetHandlers = new Map();
export function createSocketServer(httpServer) { export function createSocketServer(httpServer) {
Object.keys(PacketHandlers).forEach((key) => { Object.keys(PacketHandlers).forEach((key) => {
const {Packet, responder, validator} = PacketHandlers[key]; const {
packetHandlers.set(Packet, {responder, validator}); Packet,
limiter: limiterConfig,
responder,
validator,
} = PacketHandlers[key];
const limiter = limiterConfig && createLimiter({keyPrefix: Packet.name, ...limiterConfig});
packetHandlers.set(Packet, {limiter, responder, validator});
}); });
const socketServer = new SocketServer(httpServer, {adapter}); const socketServer = new SocketServer(httpServer, {adapter});
socketServer.io.use(socketSession(session())); socketServer.io.use(socketSession(session()));
@ -56,8 +63,23 @@ export function createSocketServer(httpServer) {
}); });
socketServer.on('connect', (socket) => { socketServer.on('connect', (socket) => {
socket.on('packet', async (packet, fn) => { socket.on('packet', async (packet, fn) => {
const {responder} = packetHandlers.get(packet.constructor); const {limiter, responder} = packetHandlers.get(packet.constructor);
responder(packet, socket, fn); if (limiter) {
try {
await limiter.consume(socket.id);
responder(packet, socket, (...args) => fn(undefined, ...args));
}
catch (error) {
if (error instanceof Error) {
fn({code: 500});
throw error;
}
fn({code: 429, ttr: Math.round(error.msBeforeNext / 1000) || 1});
}
}
else {
responder(packet, socket, (...args) => fn(undefined, ...args));
}
}); });
socket.on('disconnecting', async () => { socket.on('disconnecting', async () => {
Object.keys(socket.socket.rooms).forEach((room) => { Object.keys(socket.socket.rooms).forEach((room) => {

View File

@ -7561,6 +7561,11 @@ range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://npm.i12e.cha0s.io/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" resolved "https://npm.i12e.cha0s.io/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
rate-limiter-flexible@2.1.9:
version "2.1.9"
resolved "https://npm.i12e.cha0s.io/rate-limiter-flexible/-/rate-limiter-flexible-2.1.9.tgz#d137ffc874b8ea47d2baafe13a79474da52f8b6f"
integrity sha512-ueIXEHLZZqDBetuzyMbtSQ1Gh6Y5rw8ULoNuGA7L3xZ6njPIc2oM0ZlmsY9rS8rPU4yvdw7lk6MLbWU7WTNfnQ==
raw-body@2.4.0: raw-body@2.4.0:
version "2.4.0" version "2.4.0"
resolved "https://npm.i12e.cha0s.io/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" resolved "https://npm.i12e.cha0s.io/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"