Original address: github.com/yinxin630/b…

Note that you need to be familiar with JavaScript to read this article. This article will explain the design ideas of the core function points

Github.com/yinxin630/f…



fiora.suisuijiang.com/

preface

The project started at the end of 2015, when I first started to learn JavaScript. At that time, I just wanted to do a hands-on project. Later, with the in-depth study in the front-end field, we have been updating the technology stack, which is the fifth version after reconstruction

Thanks to the emergence of Node. js and React-Native, JSER has extended its tentacles to the server and APP ends. The server of this project is based on Node. js technology and koA framework. All data are stored in mongodb. The Client uses the React framework and uses Redux and immutable. Js to manage state. The client designs a minimalist UI style, and the APP is developed based on React-Native and Expo. The project is deployed on my Beggar version of Ali Cloud ECS, and the student computer is configured with a single core 1G memory

Server architecture

The server is responsible for two things:

  1. Provides a Websocket-based interface
  2. Provide the index.html response

The server uses the koA-socket package, which integrates socket. IO and implements the socket middleware mechanism. Based on the middleware mechanism, the server implements a set of interface routing

Each interface is an async function whose name is both the interface name and the socket event name

async login(ctx) {
    return 'login success'
}
Copy the code

Then a route middleware is written to complete the route matching. When judging the route matching, the CTX object is used as the parameter to execute the route method, and the method return value is used as the interface return value

function noop() {}

@param {IO} IO koa socket I/O instance @param {Object} routes Route */
module.exports = function (io, _io, routes) {
    Object.keys(routes).forEach((route) = > {
        io.on(route, noop); // Register events
    });

    return async (ctx) => {
        // Check whether the route exists
        if (routes[ctx.event]) {
            const { event, data, socket } = ctx;
            // Execute the route and get the returned data
            ctx.res = await routes[ctx.event]({
                event, / / the event name
                data, // Request data
                socket, // User socket instance
                io, / / koa - socket instance
                _io, / / socket. IO instance}); }}; };Copy the code

Another important middleware is catchError, which is used to catch global exceptions. Assert is widely used in business processes to determine business logic, and when the condition is not met, the process is interrupted and the error message is returned. CatchError will catch business logic exceptions and fetch the error message back to the client

const assert = require('assert');

/** * global exception catch */
module.exports = function () {
    return async (ctx, next) => {
        try {
            await next();
        } catch (err) {
            if (err instanceof assert.AssertionError) {
                ctx.res = err.message;
                return;
            }
            ctx.res = `Server Error: ${err.message}`;
            console.error('Unhandled Error\n', err); }}; };Copy the code

This is the core logic of the server side, based on which the interface is defined to form the business logic

In addition, the server is responsible for providing the index.html response, which is the home page of the client. The other resources of the client are stored on the CDN, which can relieve the bandwidth pressure of the server. However, the index. HTML cannot use strong cache, because it will make the update of the client uncontrollable, so the index. HTML is stored on the server

Client architecture

The client uses socket.io-client to connect to the server. After the connection is successful, the client requests the interface to try to log in. If the localStorage has no token or the interface returns an expired token, the client logs in as a tourist. Then ask for history messages from groups and friends

The client needs to listen for connect/disconnect/Message

  1. connect: The socket connection succeeded
  2. disconnectSocket connection down
  3. messageA new message was received. Procedure

The client uses REDUx to manage data. The data that needs to be shared by components is stored in Redux. Only the data that needs to be used by the client is stored in the state of the component.

  • User User information
    • _id user id
    • The username username
    • Linkmans contact list, including groups, friends, and temporary sessions
    • IsAdmin Whether isAdmin is an administrator
  • Focus Indicates the id of the contact in focus
  • Connect Connection status
  • UI Client UI related and function switch

The data flow of the client has two main lines

  1. User action => Request interface => Return data => Update redux => View re-render
  2. Listen for new messages => process data => update redux => View re-render

The user’s system

User Schema definitions:

const UserSchema = new Schema({
    createTime: { type: Date.default: Date.now },
    lastLoginTime: { type: Date.default: Date.now },

    username: {
        type: String.trim: true.unique: true.match: / ^ ([0-9 a zA - Z] {1, 2} | [\ u4e00 - \ u9eff]) {8} 1 $/.index: true,},salt: String.password: String.avatar: {
        type: String,}});Copy the code
  • createTime: Creation time
  • lastLoginTime: Last login time, used to clear the zombie id
  • username: user nickname, which is also an account
  • saltEncryption of salt:
  • password: User password
  • avatar: indicates the URL of the user profile picture

User registration

Username/password is required to register the interface

const {
    username, password
} = ctx.data;
assert(username, 'User name cannot be empty');
assert(password, 'Password cannot be empty');
Copy the code

Then check whether the user name already exists and obtain the default group. The newly registered user must join the default group

const user = awaitUser.findOne({ username }); assert(! user,'The username already exists');
const defaultGroup = await Group.findOne({ isDefault: true });
assert(defaultGroup, 'Default group does not exist');
Copy the code

Storing passwords in plain text is definitely not possible. Generate random salt and use salt to encrypt passwords

const salt = await bcrypt.genSalt$(saltRounds);
const hash = await bcrypt.hash$(password, salt);
Copy the code

Give the user a random default avatar, all cute girl ^_^, save the user information to the database

let newUser = null;
try {
    newUser = await User.create({
        username,
        salt,
        password: hash,
        avatar: getRandomAvatar(),
    });
} catch (err) {
    if (err.name === 'ValidationError') {
        return 'User name contains unsupported characters or exceeds length limit';
    }
    throw err;
}
Copy the code

Add the user to the default group and generate the user token. The token is a password-free login certificate stored in the client localStorage. The token contains the user ID, expiration time, and client information. The user ID and expiration time are easy to understand, the client information is to prevent token theft, we have tried to verify the consistency of the client IP, but the IP may change frequently, so that every automatic login of the user is judged as theft…

defaultGroup.members.push(newUser);
await defaultGroup.save();

const token = generateToken(newUser._id, environment);
Copy the code

The user ID is associated with the current socket connection. The server updates the current socket connection information in the socket table based on whether ctx.socket.user is undefined

ctx.socket.user = newUser._id;
await Socket.update({ id: ctx.socket.id }, {
    user: newUser._id,
    os, // Client system
    browser, // Client browser
    environment, // Client environment information
});
Copy the code

Finally, the data is returned to the client

return {
    _id: newUser._id,
    avatar: newUser.avatar,
    username: newUser.username,
    groups: [{
        _id: defaultGroup._id,
        name: defaultGroup.name,
        avatar: defaultGroup.avatar,
        creator: defaultGroup.creator,
        createTime: defaultGroup.createTime,
        messages:}], [],friends: [],
    token,
}
Copy the code

The user login

Fiora does not limit multiple logins; each user can log in from an unlimited number of terminals

There are three login cases:

  • Visitors to login
  • Token login
  • User name/password login

Visitors can only view the default group messages, and cannot send messages, mainly to reduce the experience cost of first-time users

Token login is the most common method. The client first obtains the token from localStorage. When the token exists, the client uses the token to log in

let payload = null;
try {
    payload = jwt.decode(token, config.jwtSecret);
} catch (err) {
    return 'illegal token';
}

assert(Date.now() < payload.expires, 'Token has expired');
assert.equal(environment, payload.environment, 'Illegal login');
Copy the code

Find the user information from the database, update the last login time, find the user’s group, and add the socket to the group, and then find the user’s friends

const user = await User.findOne({ _id: payload.user }, { _id: 1.avatar: 1.username: 1 });
assert(user, 'User does not exist');

user.lastLoginTime = Date.now();
await user.save();

const groups = await Group.find({ members: user }, { _id: 1.name: 1.avatar: 1.creator: 1.createTime: 1 });
groups.forEach((group) = > {
    ctx.socket.socket.join(group._id);
    return group;
});

const friends = await Friend
    .find({ from: user._id })
    .populate('to', { avatar: 1.username: 1 });
Copy the code

Update socket information, same as registration

ctx.socket.user = user._id;
await Socket.update({ id: ctx.socket.id }, {
    user: user._id,
    os,
    browser,
    environment,
});
Copy the code

Return data at last

The user name/password is different from the token login logic at the beginning. The token authentication data is not decoded to verify whether the user name exists and then verify whether the password matches

const user = await User.findOne({ username });
assert(user, 'This user does not exist');

const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, 'Password error');
Copy the code

The logic is then consistent with token login

The messaging system

Send a message

The sendMessage interface takes three parameters:

  • to: Indicates the object, group or user to be sent
  • type: Message type
  • content: Message content

Since group chat and private chat share the same interface, it is necessary to determine whether it is group chat or private chat and obtain the group ID or user ID. Group chat/private chat can be distinguished by the parameter to to indicate the corresponding group ID in group chat, and then obtain the group information. Private chat to is the result of id splicing between sender and receiver. Remove the sender ID to get the recipient ID, and then get the recipient information

let groupId = ' ';
let userId = ' ';
if (isValid(to)) {
    const group = await Group.findOne({ _id: to });
    assert(group, 'Group does not exist');
} else {
    userId = to.replace(ctx.socket.user, ' ');
    assert(isValid(userId), 'Invalid user ID');
    const user = await User.findOne({ _id: userId });
    assert(user, 'User does not exist');
}
Copy the code

Some message types need to be processed. Text messages determine the length and perform XSS processing. Invite messages determine whether the invited group exists and store information such as the inviter, group ID, and group name in the message body

let messageContent = content;
if (type === 'text') {
    assert(messageContent.length <= 2048.'Message length is too long');
    messageContent = xss(content);
} else if (type === 'invite') {
    const group = await Group.findOne({ name: content });
    assert(group, 'Target group does not exist');

    const user = await User.findOne({ _id: ctx.socket.user });
    messageContent = JSON.stringify({
        inviter: user.username,
        groupId: group._id,
        groupName: group.name,
    });
}
Copy the code

Store the new message to the database

let message;
try {
    message = await Message.create({
        from: ctx.socket.user,
        to,
        type,
        content: messageContent,
    });
} catch (err) {
    throw err;
}
Copy the code

Then construct a message does not contain sensitive information data, the data contained in the sender’s id, username, avatar, including user name and image is the redundant data, consideration will be optimized after only a id, client user information maintenance, by id to match the user name and avatar, can save a lot of traffic If it is a group chat message, Private messaging is a bit more complicated because FiORA allows multiple logins, so you need to push all of the recipient’s online sockets first, and then the rest of fiORA’s online sockets

const user = await User.findOne({ _id: ctx.socket.user }, { username: 1.avatar: 1 });
const messageData = {
    _id: message._id,
    createTime: message.createTime,
    from: user.toObject(),
    to,
    type,
    content: messageContent,
};

if (groupId) {
    ctx.socket.socket.to(groupId).emit('message', messageData);
} else {
    const sockets = await Socket.find({ user: userId });
    sockets.forEach((socket) = > {
        ctx._io.to(socket.id).emit('message', messageData);
    });
    const selfSockets = await Socket.find({ user: ctx.socket.user });
    selfSockets.forEach((socket) = > {
        if(socket.id ! == ctx.socket.id) { ctx._io.to(socket.id).emit('message', messageData); }}); }Copy the code

Finally, the message data is returned to the client, indicating that the message is successfully sent. To optimize user experience, the client immediately displays new information on the page when sending messages and requests the interface to send messages. If the message fails to be sent, it is deleted

Get historical messages

GetLinkmanHistoryMessages interface has two parameters:

  • linkmanId: Contact ID, group ID, or combination of two user ids
  • existCount: Indicates the number of existing messages

The detailed logic is relatively simple, according to the creation time reverse order to find the existing number + each time to obtain the number of messages, and then remove the existing number of messages and then reverse, is the new message sorted by time

const messages = await Message
    .find(
        { to: linkmanId },
        { type: 1.content: 1.from: 1.createTime: 1 },
        { sort: { createTime: - 1 }, limit: EachFetchMessagesCount + existCount },
    )
    .populate('from', { username: 1.avatar: 1 });
const result = messages.slice(existCount).reverse();
Copy the code

Return to the client

Receiving Push Messages

Client subscribes message event to receive new message socket.on(‘message’)

When receiving a new message, judge whether the contact exists in the state, if so, save the message to the corresponding contact, if not, it is a message of a temporary session, construct a temporary contact and obtain the historical message, and then add the temporary contact to the state. If the message is from another terminal, you do not need to create a contact

const state = store.getState();
const isSelfMessage = message.from._id === state.getIn(['user'.'_id']);
const linkman = state.getIn(['user'.'linkmans']).find(l= > l.get('_id') === message.to);
let title = ' ';
if (linkman) {
    action.addLinkmanMessage(message.to, message);
    if (linkman.get('type') = = ='group') {
        title = `${message.from.username}${linkman.get('name')}Say to everybody: ';
    } else {
        title = `${message.from.username}Say to you: `; }}else {
    // The contact does not exist and the message is sent by itself. No new contact is created
    if (isSelfMessage) {
        return;
    }
    const newLinkman = {
        _id: getFriendId(
            state.getIn(['user'.'_id']),
            message.from._id,
        ),
        type: 'temporary'.createTime: Date.now(),
        avatar: message.from.avatar,
        name: message.from.username,
        messages: [].unread: 1}; action.addLinkman(newLinkman); title =`${message.from.username}Say to you: `;

    fetch('getLinkmanHistoryMessages', { linkmanId: newLinkman._id }).then(([err, res]) = > {
        if (!err) {
            action.addLinkmanMessages(newLinkman._id, res);
        }
    });
}
Copy the code

If the current chat page is in the background and the message notification switch is turned on, a desktop reminder pops up

if (windowStatus === 'blur' && state.getIn(['ui'.'notificationSwitch'])) {
    notification(
        title,
        message.from.avatar,
        message.type === 'text' ? message.content : ` [${message.type}] `.Math.random(),
    );
}
Copy the code

If the sound switch is turned on, a new message prompt tone will sound

if (state.getIn(['ui'.'soundSwitch']) {const soundType = state.getIn(['ui'.'sound']);
    sound(soundType);
}
Copy the code

If the language broadcast switch is turned on and the message is a text message, filter out the URL and # in the message, exclude the message with a length greater than 200, and then push the message to the message reading queue

if (message.type === 'text' && state.getIn(['ui'.'voiceSwitch']) {const text = message.content
        .replace(/https? :\/\/(www\.) ? [9 - a - zA - Z0 - @ : %. _ + ~ # =] {2256} \. [a-z] {2, 6} \ b ([9 - a - zA - Z0 - @ : % _ +. ~ #? & / / =] *)/g.' ')
        .replace(/#/g.' ');
    // The maximum number of words is 200
    if (text.length > 200) {
        return;
    }

    const from = linkman && linkman.get('type') = = ='group' ?
        `${message.from.username}in${linkman.get('name')}Said `
        :
        `${message.from.username}` said to you;
    if (text) {
        voice.push(from! == prevFrom ?from + text : text, message.from.username);
    }
    prevFrom = from;
}
Copy the code

More Middleware

Restrict unlogged requests

Most interfaces are accessible only to logged in users. If the interface needs to log in and the socket connection has no user information, an “logged in” error is displayed

/** * Intercepts unlogged requests */
module.exports = function () {
    const noUseLoginEvent = {
        register: true.login: true.loginByToken: true.guest: true.getDefalutGroupHistoryMessages: true.getDefaultGroupOnlineMembers: true};return async (ctx, next) => {
        if(! noUseLoginEvent[ctx.event] && ! ctx.socket.user) { ctx.res ='Please login and try again';
            return;
        }
        await next();
    };
};
Copy the code

Limit call frequency

To prevent interface flushing and reduce server pressure, the maximum number of interface requests per minute for a socket connection is 30

const MaxCallPerMinutes = 30;
/** * Limiting the frequency of interface calls */
module.exports = function () {
    let callTimes = {};
    setInterval((a)= > callTimes = {}, 60000); // Emptying every 60 seconds

    return async (ctx, next) => {
        const socketId = ctx.socket.id;
        const count = callTimes[socketId] || 0;
        if (count >= MaxCallPerMinutes) {
            return ctx.res = 'Interface calls frequently';
        }
        callTimes[socketId] = count + 1;
        await next();
    };
};
Copy the code

The little black house

The administrator account can add users to the small black room, the user added to the small black room can not request any interface, after 10 minutes automatically unban

/** * Refusing to seal user requests */
module.exports = function () {
    return async (ctx, next) => {
        const sealList = global.mdb.get('sealList');
        if (ctx.socket.user && sealList.has(ctx.socket.user.toString())) {
            return ctx.res = 'You've been locked in a dark room. Please reflect and try again.';
        }

        await next();
    };
};
Copy the code

Other interesting things

expression

Emoticon is a Sprite image. Clicking on emoticon will insert the format as#(xx)For example# (funny). When rendering the message, the text is replaced by a regular match<img>And calculate the position of the expression in the Sprite image and render it on the pageIf SRC is not set, a border will be displayed. You need to set SRC to a transparent image

function convertExpression(txt) {
    return txt.replace(
        /#\(([\u4e00-\u9fa5a-z]+)\)/g,
        (r, e) => {
            const index = expressions.default.indexOf(e);
            if(index ! = =- 1) {
                return `<img class="expression-baidu" src="${transparentImage}" style="background-position: left The ${- 30 * index}px;" onerror="this.style.display='none'" alt="${r}"> `;
            }
            returnr; }); }Copy the code

Emoji Search

Search results on www.doutula.com

const res = await axios.get(`https://www.doutula.com/search?keyword=The ${encodeURIComponent(keywords)}`);
assert(res.status === 200.'Emoji search failed, please try again');

const images = res.data.match(/data-original="[^ "]+"/g) | | [];return images.map(i= > i.substring(15, i.length - 1));
Copy the code

Desktop Message Notification

Many people ask how this is implemented. In fact, it is a new feature of HTML5 called Notification. For more information, check out developer.mozilla.org/en-US/docs/…

Paste the hair figure

Listen for the paste event to get the paste content, and if it contains content of type Files, read the content and generate an Image object. Note: the image obtained in this way will be much larger than the original image, so it is best to compress it before using

@autobind
handlePaste(e) {
    const { items, types } = (e.clipboardData || e.originalEvent.clipboardData);

    // If file contents are included
    if (types.indexOf('Files') > - 1) {
        for (let index = 0; index < items.length; index++) {
            const item = items[index];
            if (item.kind === 'file') {
                const file = item.getAsFile();
                if (file) {
                    const that = this;
                    const reader = new FileReader();
                    reader.onloadend = function () {
                        const image = new Image();
                        image.onload = (a)= > {
                            // Get the image object
                        };
                        image.src = this.result; }; reader.readAsDataURL(file); } } } e.preventDefault(); }}Copy the code

Language broadcasts

This is baidu’s language synthesis service, thanks Baidu. Check out ai.baidu.com/tech/speech…

Version history

Original version

Changed the background and style

React rewrites the fiora name

The style is becoming more quadratic, with some new features

An experimental version that never made it online

The current version of online running

The latter

If you have any questions about Fiora, please feel free to contact fiora.suisuijiang.com/. I will be online every day