chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:50 +08:00
commit cc8f841fc5
422 changed files with 70222 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import Koa from 'koa';
import koaSend from 'koa-send';
import koaStatic from 'koa-static';
import path from 'path';
import http from 'http';
import { Server } from 'socket.io';
import logger from '@fiora/utils/logger';
import config from '@fiora/config/server';
import { getSocketIp } from '@fiora/utils/socket';
import SocketModel, {
SocketDocument,
} from '@fiora/database/mongoose/models/socket';
import seal from './middlewares/seal';
import frequency from './middlewares/frequency';
import isLogin from './middlewares/isLogin';
import isAdmin from './middlewares/isAdmin';
import * as userRoutes from './routes/user';
import * as groupRoutes from './routes/group';
import * as messageRoutes from './routes/message';
import * as systemRoutes from './routes/system';
import * as notificationRoutes from './routes/notification';
import * as historyRoutes from './routes/history';
import registerRoutes from './middlewares/registerRoutes';
const app = new Koa();
app.proxy = true;
const httpServer = http.createServer(app.callback());
const io = new Server(httpServer, {
cors: {
origin: config.allowOrigin || '*',
credentials: true,
},
pingTimeout: 10000,
pingInterval: 5000,
});
// serve index.html
app.use(async (ctx, next) => {
if (
/\/invite\/group\/[\w\d]+/.test(ctx.request.url) ||
!/(\.)|(\/invite\/group\/[\w\d]+)/.test(ctx.request.url)
) {
await koaSend(ctx, 'index.html', {
root: path.join(__dirname, '../public'),
maxage: 1000 * 60 * 60 * 24 * 7,
gzip: true,
});
} else {
await next();
}
});
// serve public static files
app.use(
koaStatic(path.join(__dirname, '../public'), {
maxAge: 1000 * 60 * 60 * 24 * 7,
gzip: true,
}),
);
const routes: Routes = {
...userRoutes,
...groupRoutes,
...messageRoutes,
...systemRoutes,
...notificationRoutes,
...historyRoutes,
};
Object.keys(routes).forEach((key) => {
if (key.startsWith('_')) {
routes[key] = null;
}
});
io.on('connection', async (socket) => {
const ip = getSocketIp(socket);
logger.trace(`connection ${socket.id} ${ip}`);
await SocketModel.create({
id: socket.id,
ip,
} as SocketDocument);
socket.on('disconnect', async () => {
logger.trace(`disconnect ${socket.id}`);
await SocketModel.deleteOne({
id: socket.id,
});
});
socket.use(seal(socket));
socket.use(isLogin(socket));
socket.use(isAdmin(socket));
socket.use(frequency(socket));
socket.use(registerRoutes(socket, routes));
});
export default httpServer;
+38
View File
@@ -0,0 +1,38 @@
import config from '@fiora/config/server';
import getRandomAvatar from '@fiora/utils/getRandomAvatar';
import { doctor } from '@fiora/bin/scripts/doctor';
import logger from '@fiora/utils/logger';
import initMongoDB from '@fiora/database/mongoose/initMongoDB';
import Socket from '@fiora/database/mongoose/models/socket';
import Group, { GroupDocument } from '@fiora/database/mongoose/models/group';
import app from './app';
(async () => {
if (process.argv.find((argv) => argv === '--doctor')) {
await doctor();
}
await initMongoDB();
// 判断默认群是否存在, 不存在就创建一个
const group = await Group.findOne({ isDefault: true });
if (!group) {
const defaultGroup = await Group.create({
name: 'fiora',
avatar: getRandomAvatar(),
isDefault: true,
} as GroupDocument);
if (!defaultGroup) {
logger.error('[defaultGroup]', 'create default group fail');
return process.exit(1);
}
}
app.listen(config.port, async () => {
await Socket.deleteMany({}); // 删除Socket表所有历史数据
logger.info(`>>> server listen on http://localhost:${config.port}`);
});
return null;
})();
@@ -0,0 +1,75 @@
import { Socket } from 'socket.io';
import {
getNewUserKey,
getSealUserKey,
Redis,
} from '@fiora/database/redis/initRedis';
export const CALL_SERVICE_FREQUENTLY = '发消息过于频繁, 请冷静一会再试';
export const NEW_USER_CALL_SERVICE_FREQUENTLY =
'发消息过于频繁, 你还处于萌新期, 不要恶意刷屏, 先冷静一会再试';
const MaxCallPerMinutes = 20;
const NewUserMaxCallPerMinutes = 5;
const ClearDataInterval = 60000;
const AutoSealDuration = 5; // minutes
type Options = {
maxCallPerMinutes?: number;
newUserMaxCallPerMinutes?: number;
clearDataInterval?: number;
};
/**
* 限制接口调用频率
* 新用户限制每分钟5次, 老用户限制每分钟20次
*/
export default function frequency(
socket: Socket,
{
maxCallPerMinutes = MaxCallPerMinutes,
newUserMaxCallPerMinutes = NewUserMaxCallPerMinutes,
clearDataInterval = ClearDataInterval,
}: Options = {},
) {
let callTimes: Record<string, number> = {};
// 每60s清空一次次数统计
setInterval(() => {
callTimes = {};
}, clearDataInterval);
return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => {
if (event !== 'sendMessage') {
next();
} else {
const socketId = socket.id;
const count = callTimes[socketId] || 0;
const isNewUser =
socket.data.user &&
(await Redis.has(getNewUserKey(socket.data.user)));
if (isNewUser && count >= newUserMaxCallPerMinutes) {
// new user limit
cb(NEW_USER_CALL_SERVICE_FREQUENTLY);
await Redis.set(
getSealUserKey(socket.data.user),
socket.data.user,
Redis.Minute * AutoSealDuration,
);
} else if (count >= maxCallPerMinutes) {
// normal user limit
cb(CALL_SERVICE_FREQUENTLY);
await Redis.set(
getSealUserKey(socket.data.user),
socket.data.user,
Redis.Minute * AutoSealDuration,
);
} else {
callTimes[socketId] = count + 1;
next();
}
}
};
}
@@ -0,0 +1,33 @@
import config from '@fiora/config/server';
import { Socket } from 'socket.io';
export const YOU_ARE_NOT_ADMINISTRATOR = '你不是管理员';
/**
* 拦截非管理员用户请求需要管理员权限的接口
*/
export default function isAdmin(socket: Socket) {
const requireAdminEvent = new Set([
'sealUser',
'getSealList',
'resetUserPassword',
'setUserTag',
'getUserIps',
'sealIp',
'getSealIpList',
'toggleSendMessage',
'toggleNewUserSendMessage',
'getSystemConfig',
]);
return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => {
socket.data.isAdmin =
!!socket.data.user &&
config.administrator.includes(socket.data.user);
const isAdminEvent = requireAdminEvent.has(event);
if (!socket.data.isAdmin && isAdminEvent) {
cb(YOU_ARE_NOT_ADMINISTRATOR);
} else {
next();
}
};
}
@@ -0,0 +1,27 @@
import { Socket } from 'socket.io';
export const PLEASE_LOGIN = '请登录后再试';
/**
* 拦截未登录用户请求需要登录态的接口
*/
export default function isLogin(socket: Socket) {
const noRequireLoginEvent = new Set([
'register',
'login',
'loginByToken',
'guest',
'getDefaultGroupHistoryMessages',
'getDefaultGroupOnlineMembers',
'getBaiduToken',
'getGroupBasicInfo',
'getSTS',
]);
return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => {
if (!noRequireLoginEvent.has(event) && !socket.data.user) {
cb(PLEASE_LOGIN);
} else {
next();
}
};
}
@@ -0,0 +1,59 @@
import assert from 'assert';
import logger from '@fiora/utils/logger';
import { getSocketIp } from '@fiora/utils/socket';
import { Socket } from 'socket.io';
function defaultCallback() {
logger.error('Server Error: emit event with callback');
}
export default function registerRoutes(socket: Socket, routes: Routes) {
return async ([event, data, cb = defaultCallback]: MiddlewareArgs) => {
const route = routes[event];
if (route) {
try {
const ctx: Context<any> = {
data,
socket: {
id: socket.id,
ip: getSocketIp(socket),
get user() {
return socket.data.user;
},
set user(newUserId: string) {
socket.data.user = newUserId;
},
get isAdmin() {
return socket.data.isAdmin;
},
join: socket.join.bind(socket),
leave: socket.leave.bind(socket),
emit: (target, _event, _data) => {
socket.to(target).emit(_event, _data);
},
},
};
const before = Date.now();
const res = await route(ctx);
const after = Date.now();
logger.info(
`[${event}]`,
after - before,
ctx.socket.id,
ctx.socket.user || 'null',
typeof res === 'string' ? res : 'null',
);
cb(res);
} catch (err) {
if (err instanceof assert.AssertionError) {
cb(err.message);
} else {
logger.error(`[${event}]`, err.message);
cb(`Server Error: ${err.message}`);
}
}
} else {
cb(`Server Error: event [${event}] not exists`);
}
};
}
+27
View File
@@ -0,0 +1,27 @@
import { SEAL_TEXT } from '@fiora/utils/const';
import { getSocketIp } from '@fiora/utils/socket';
import { Socket } from 'socket.io';
import {
getSealIpKey,
getSealUserKey,
Redis,
} from '@fiora/database/redis/initRedis';
/**
* 拦截被封禁用户的请求
*/
export default function seal(socket: Socket) {
return async ([, , cb]: MiddlewareArgs, next: MiddlewareNext) => {
const ip = getSocketIp(socket);
const isSealIp = await Redis.has(getSealIpKey(ip));
const isSealUser =
socket.data.user &&
(await Redis.has(getSealUserKey(socket.data.user)));
if (isSealUser || isSealIp) {
cb(SEAL_TEXT);
} else {
next();
}
};
}
+337
View File
@@ -0,0 +1,337 @@
import assert, { AssertionError } from 'assert';
import { Types } from '@fiora/database/mongoose';
import stringHash from 'string-hash';
import config from '@fiora/config/server';
import getRandomAvatar from '@fiora/utils/getRandomAvatar';
import Group, { GroupDocument } from '@fiora/database/mongoose/models/group';
import Socket from '@fiora/database/mongoose/models/socket';
import Message from '@fiora/database/mongoose/models/message';
const { isValid } = Types.ObjectId;
/**
* 获取指定群组的在线用户辅助方法
* @param group 群组
*/
async function getGroupOnlineMembersHelper(group: GroupDocument) {
const sockets = await Socket.find(
{
user: {
$in: group.members.map((member) => member.toString()),
},
},
{
os: 1,
browser: 1,
environment: 1,
user: 1,
},
).populate('user', { username: 1, avatar: 1 });
const filterSockets = sockets.reduce((result, socket) => {
result.set(socket.user._id.toString(), socket);
return result;
}, new Map());
return Array.from(filterSockets.values());
}
/**
* 创建群组
* @param ctx Context
*/
export async function createGroup(ctx: Context<{ name: string }>) {
assert(!config.disableCreateGroup, '管理员已关闭创建群组功能');
const ownGroupCount = await Group.count({ creator: ctx.socket.user });
assert(
ctx.socket.isAdmin || ownGroupCount < config.maxGroupsCount,
`创建群组失败, 你已经创建了${config.maxGroupsCount}个群组`,
);
const { name } = ctx.data;
assert(name, '群组名不能为空');
const group = await Group.findOne({ name });
assert(!group, '该群组已存在');
let newGroup = null;
try {
newGroup = await Group.create({
name,
avatar: getRandomAvatar(),
creator: ctx.socket.user,
members: [ctx.socket.user],
} as GroupDocument);
} catch (err) {
if (err.name === 'ValidationError') {
return '群组名包含不支持的字符或者长度超过限制';
}
throw err;
}
ctx.socket.join(newGroup._id.toString());
return {
_id: newGroup._id,
name: newGroup.name,
avatar: newGroup.avatar,
createTime: newGroup.createTime,
creator: newGroup.creator,
};
}
/**
* 加入群组
* @param ctx Context
*/
export async function joinGroup(ctx: Context<{ groupId: string }>) {
const { groupId } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '加入群组失败, 群组不存在' });
}
assert(group.members.indexOf(ctx.socket.user) === -1, '你已经在群组中');
group.members.push(ctx.socket.user);
await group.save();
const messages = await Message.find(
{ toGroup: groupId },
{
type: 1,
content: 1,
from: 1,
createTime: 1,
},
{ sort: { createTime: -1 }, limit: 3 },
).populate('from', { username: 1, avatar: 1 });
messages.reverse();
ctx.socket.join(group._id.toString());
return {
_id: group._id,
name: group.name,
avatar: group.avatar,
createTime: group.createTime,
creator: group.creator,
messages,
};
}
/**
* 退出群组
* @param ctx Context
*/
export async function leaveGroup(ctx: Context<{ groupId: string }>) {
const { groupId } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
// 默认群组没有creator
if (group.creator) {
assert(
group.creator.toString() !== ctx.socket.user.toString(),
'群主不可以退出自己创建的群',
);
}
const index = group.members.indexOf(ctx.socket.user);
assert(index !== -1, '你不在群组中');
group.members.splice(index, 1);
await group.save();
ctx.socket.leave(group._id.toString());
return {};
}
const GroupOnlineMembersCacheExpireTime = 1000 * 60;
/**
* 获取群组在线成员
*/
function getGroupOnlineMembersWrapperV2() {
const cache: Record<
string,
{
key?: string;
value: any;
expireTime: number;
}
> = {};
return async function getGroupOnlineMembersV2(
ctx: Context<{ groupId: string; cache?: string }>,
) {
const { groupId, cache: cacheKey } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
if (
cache[groupId] &&
cache[groupId].key === cacheKey &&
cache[groupId].expireTime > Date.now()
) {
return { cache: cacheKey };
}
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
const result = await getGroupOnlineMembersHelper(group);
const resultCacheKey = stringHash(
result.map((item) => item.user._id).join(','),
).toString(36);
if (cache[groupId] && cache[groupId].key === resultCacheKey) {
cache[groupId].expireTime =
Date.now() + GroupOnlineMembersCacheExpireTime;
if (resultCacheKey === cacheKey) {
return { cache: cacheKey };
}
}
cache[groupId] = {
key: resultCacheKey,
value: result,
expireTime: Date.now() + GroupOnlineMembersCacheExpireTime,
};
return {
cache: resultCacheKey,
members: result,
};
};
}
export const getGroupOnlineMembersV2 = getGroupOnlineMembersWrapperV2();
export async function getGroupOnlineMembers(
ctx: Context<{ groupId: string; cache?: string }>,
) {
const result = await getGroupOnlineMembersV2(ctx);
return result.members;
}
/**
* 获取默认群组的在线成员
* 无需登录态
*/
function getDefaultGroupOnlineMembersWrapper() {
let cache: any = null;
let expireTime = 0;
return async function getDefaultGroupOnlineMembers() {
if (cache && expireTime > Date.now()) {
return cache;
}
const group = await Group.findOne({ isDefault: true });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
cache = await getGroupOnlineMembersHelper(group);
expireTime = Date.now() + GroupOnlineMembersCacheExpireTime;
return cache;
};
}
export const getDefaultGroupOnlineMembers = getDefaultGroupOnlineMembersWrapper();
/**
* 修改群头像, 只有群创建者有权限
* @param ctx Context
*/
export async function changeGroupAvatar(
ctx: Context<{ groupId: string; avatar: string }>,
) {
const { groupId, avatar } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
assert(avatar, '头像地址不能为空');
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
assert(
group.creator.toString() === ctx.socket.user.toString(),
'只有群主才能修改头像',
);
await Group.updateOne({ _id: groupId }, { avatar });
return {};
}
/**
* 修改群组头像, 只有群创建者有权限
* @param ctx Context
*/
export async function changeGroupName(
ctx: Context<{ groupId: string; name: string }>,
) {
const { groupId, name } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
assert(name, '群组名称不能为空');
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
assert(group.name !== name, '新群组名不能和之前一致');
assert(
group.creator.toString() === ctx.socket.user.toString(),
'只有群主才能修改头像',
);
const targetGroup = await Group.findOne({ name });
assert(!targetGroup, '该群组名已存在');
await Group.updateOne({ _id: groupId }, { name });
ctx.socket.emit(groupId, 'changeGroupName', { groupId, name });
return {};
}
/**
* 删除群组, 只有群创建者有权限
* @param ctx Context
*/
export async function deleteGroup(ctx: Context<{ groupId: string }>) {
const { groupId } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
assert(
group.creator.toString() === ctx.socket.user.toString(),
'只有群主才能解散群组',
);
assert(group.isDefault !== true, '默认群组不允许解散');
await Group.deleteOne({ _id: group });
ctx.socket.emit(groupId, 'deleteGroup', { groupId });
return {};
}
export async function getGroupBasicInfo(ctx: Context<{ groupId: string }>) {
const { groupId } = ctx.data;
assert(isValid(groupId), '无效的群组ID');
const group = await Group.findOne({ _id: groupId });
if (!group) {
throw new AssertionError({ message: '群组不存在' });
}
return {
_id: group._id,
name: group.name,
avatar: group.avatar,
members: group.members.length,
};
}
+36
View File
@@ -0,0 +1,36 @@
import { isValidObjectId, Types } from '@fiora/database/mongoose';
import assert from 'assert';
import User from '@fiora/database/mongoose/models/user';
import Group from '@fiora/database/mongoose/models/group';
import Message from '@fiora/database/mongoose/models/message';
import { createOrUpdateHistory } from '@fiora/database/mongoose/models/history';
export async function updateHistory(
ctx: Context<{ userId: string; linkmanId: string; messageId: string }>,
) {
const { linkmanId, messageId } = ctx.data;
const self = ctx.socket.user.toString();
if (!Types.ObjectId.isValid(messageId)) {
return {
msg: `not update with invalid messageId:${messageId}`,
};
}
// @ts-ignore
const [user, linkman, message] = await Promise.all([
User.findOne({ _id: self }),
isValidObjectId(linkmanId)
? Group.findOne({ _id: linkmanId })
: User.findOne({ _id: linkmanId.replace(self, '') }),
Message.findOne({ _id: messageId }),
]);
assert(user, '用户不存在');
assert(linkman, '联系人不存在');
assert(message, '消息不存在');
await createOrUpdateHistory(self, linkmanId, messageId);
return {
msg: 'ok',
};
}
+477
View File
@@ -0,0 +1,477 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import assert, { AssertionError } from 'assert';
import { Types } from '@fiora/database/mongoose';
import { Expo, ExpoPushErrorTicket } from 'expo-server-sdk';
import xss from '@fiora/utils/xss';
import logger from '@fiora/utils/logger';
import User, { UserDocument } from '@fiora/database/mongoose/models/user';
import Group, { GroupDocument } from '@fiora/database/mongoose/models/group';
import Message, {
handleInviteV2Message,
handleInviteV2Messages,
MessageDocument,
} from '@fiora/database/mongoose/models/message';
import Notification from '@fiora/database/mongoose/models/notification';
import History, {
createOrUpdateHistory,
} from '@fiora/database/mongoose/models/history';
import Socket from '@fiora/database/mongoose/models/socket';
import {
DisableSendMessageKey,
DisableNewUserSendMessageKey,
Redis,
} from '@fiora/database/redis/initRedis';
import client from '../../../config/client';
const { isValid } = Types.ObjectId;
/** 初次获取历史消息数 */
const FirstTimeMessagesCount = 15;
/** 每次调用接口获取的历史消息数 */
const EachFetchMessagesCount = 30;
const OneYear = 365 * 24 * 3600 * 1000;
/** 石头剪刀布, 用于随机生成结果 */
const RPS = ['石头', '剪刀', '布'];
async function pushNotification(
notificationTokens: string[],
message: MessageDocument,
groupName?: string,
) {
const expo = new Expo({});
const content =
message.type === 'text' ? message.content : `[${message.type}]`;
const pushMessages = notificationTokens.map((notificationToken) => ({
to: notificationToken,
sound: 'default',
title: groupName || (message.from as any).username,
body: groupName
? `${(message.from as any).username}: ${content}`
: content,
data: { focus: message.to },
}));
const chunks = expo.chunkPushNotifications(pushMessages as any);
for (const chunk of chunks) {
try {
const results = await expo.sendPushNotificationsAsync(chunk);
results.forEach((result) => {
const { status, message: errMessage } =
result as ExpoPushErrorTicket;
if (status === 'error') {
logger.warn('[Notification]', errMessage);
}
});
} catch (error) {
logger.error('[Notification]', (error as Error).message);
}
}
}
/**
* 发送消息
* 如果是发送给群组, to是群组id
* 如果是发送给个人, to是俩人id按大小序拼接后的值
* @param ctx Context
*/
export async function sendMessage(ctx: Context<SendMessageData>) {
const disableSendMessage = await Redis.get(DisableSendMessageKey);
assert(disableSendMessage !== 'true' || ctx.socket.isAdmin, '全员禁言中');
const disableNewUserSendMessage = await Redis.get(
DisableNewUserSendMessageKey,
);
if (disableNewUserSendMessage === 'true') {
const user = await User.findById(ctx.socket.user);
const isNewUser =
user && user.createTime.getTime() > Date.now() - OneYear;
assert(
ctx.socket.isAdmin || !isNewUser,
'新用户禁言中! 主群禁止闲聊, 多交流fiora和开发技术, 自发维护交流环境',
);
}
const { to, content } = ctx.data;
let { type } = ctx.data;
assert(to, 'to不能为空');
let toGroup: GroupDocument | null = null;
let toUser: UserDocument | null = null;
if (isValid(to)) {
toGroup = await Group.findOne({ _id: to });
assert(toGroup, '群组不存在');
} else {
const userId = to.replace(ctx.socket.user.toString(), '');
assert(isValid(userId), '无效的用户ID');
toUser = await User.findOne({ _id: userId });
assert(toUser, '用户不存在');
}
let messageContent = content;
if (type === 'text') {
assert(messageContent.length <= 2048, '消息长度过长');
const rollRegex = /^-roll( ([0-9]*))?$/;
if (rollRegex.test(messageContent)) {
const regexResult = rollRegex.exec(messageContent);
if (regexResult) {
let numberStr = regexResult[1] || '100';
if (numberStr.length > 5) {
numberStr = '99999';
}
const number = parseInt(numberStr, 10);
type = 'system';
messageContent = JSON.stringify({
command: 'roll',
value: Math.floor(Math.random() * (number + 1)),
top: number,
});
}
} else if (/^-rps$/.test(messageContent)) {
type = 'system';
messageContent = JSON.stringify({
command: 'rps',
value: RPS[Math.floor(Math.random() * RPS.length)],
});
}
messageContent = xss(messageContent);
} else if (type === 'file') {
const file: { size: number } = JSON.parse(content);
assert(file.size < client.maxFileSize, '要发送的文件过大');
messageContent = content;
} else if (type === 'inviteV2') {
const shareTargetGroup = await Group.findOne({ _id: content });
if (!shareTargetGroup) {
throw new AssertionError({ message: '目标群组不存在' });
}
const user = await User.findOne({ _id: ctx.socket.user });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
messageContent = JSON.stringify({
inviter: user._id,
group: shareTargetGroup._id,
});
}
const user = await User.findOne(
{ _id: ctx.socket.user },
{ username: 1, avatar: 1, tag: 1 },
);
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
const message = await Message.create({
from: ctx.socket.user,
to,
type,
content: messageContent,
} as MessageDocument);
const messageData = {
_id: message._id,
createTime: message.createTime,
from: user.toObject(),
to,
type,
content: message.content,
};
if (type === 'inviteV2') {
await handleInviteV2Message(messageData);
}
if (toGroup) {
ctx.socket.emit(toGroup._id.toString(), 'message', messageData);
const notifications = await Notification.find({
user: {
$in: toGroup.members,
},
});
const notificationTokens: string[] = [];
notifications.forEach((notification) => {
// Messages sent by yourself dont push notification to yourself
if (
notification.user._id.toString() === ctx.socket.user.toString()
) {
return;
}
notificationTokens.push(notification.token);
});
if (notificationTokens.length) {
pushNotification(
notificationTokens,
messageData as unknown as MessageDocument,
toGroup.name,
);
}
} else {
const targetSockets = await Socket.find({ user: toUser?._id });
const targetSocketIdList =
targetSockets?.map((socket) => socket.id) || [];
if (targetSocketIdList.length) {
ctx.socket.emit(targetSocketIdList, 'message', messageData);
}
const selfSockets = await Socket.find({ user: ctx.socket.user });
const selfSocketIdList = selfSockets?.map((socket) => socket.id) || [];
if (selfSocketIdList.length) {
ctx.socket.emit(selfSocketIdList, 'message', messageData);
}
const notificationTokens = await Notification.find({ user: toUser });
if (notificationTokens.length) {
pushNotification(
notificationTokens.map(({ token }) => token),
messageData as unknown as MessageDocument,
);
}
}
createOrUpdateHistory(ctx.socket.user.toString(), to, message._id);
return messageData;
}
/**
* 获取一组联系人的最后历史消息
* @param ctx Context
*/
export async function getLinkmansLastMessages(
ctx: Context<{ linkmans: string[] }>,
) {
const { linkmans } = ctx.data;
assert(Array.isArray(linkmans), '参数linkmans应该是Array');
const promises = linkmans.map(async (linkmanId) => {
const messages = await Message.find(
{ to: linkmanId },
{
type: 1,
content: 1,
from: 1,
createTime: 1,
deleted: 1,
},
{ sort: { createTime: -1 }, limit: FirstTimeMessagesCount },
).populate('from', { username: 1, avatar: 1, tag: 1 });
await handleInviteV2Messages(messages);
return messages;
});
const results = await Promise.all(promises);
type Messages = {
[linkmanId: string]: MessageDocument[];
};
const messages = linkmans.reduce((result: Messages, linkmanId, index) => {
result[linkmanId] = (results[index] || []).reverse();
return result;
}, {});
return messages;
}
export async function getLinkmansLastMessagesV2(
ctx: Context<{ linkmans: string[] }>,
) {
const { linkmans } = ctx.data;
const histories = await History.find({
user: ctx.socket.user.toString(),
linkman: {
$in: linkmans,
},
});
const historyMap = histories
.filter(Boolean)
.reduce((result: { [linkman: string]: string }, history) => {
result[history.linkman] = history.message;
return result;
}, {});
const linkmansMessages = await Promise.all(
linkmans.map(async (linkmanId) => {
const messages = await Message.find(
{ to: linkmanId },
{
type: 1,
content: 1,
from: 1,
createTime: 1,
deleted: 1,
},
{
sort: { createTime: -1 },
limit: historyMap[linkmanId] ? 100 : FirstTimeMessagesCount,
},
).populate('from', { username: 1, avatar: 1, tag: 1 });
await handleInviteV2Messages(messages);
return messages;
}),
);
type ResponseData = {
[linkmanId: string]: {
messages: MessageDocument[];
unread: number;
};
};
const responseData = linkmans.reduce(
(result: ResponseData, linkmanId, index) => {
const messages = linkmansMessages[index];
if (historyMap[linkmanId]) {
const messageIndex = messages.findIndex(
({ _id }) => _id.toString() === historyMap[linkmanId],
);
result[linkmanId] = {
messages: messages.slice(0, 15).reverse(),
unread: messageIndex === -1 ? 100 : messageIndex,
};
} else {
result[linkmanId] = {
messages: messages.reverse(),
unread: 0,
};
}
return result;
},
{},
);
return responseData;
}
/**
* 获取联系人的历史消息
* @param ctx Context
*/
export async function getLinkmanHistoryMessages(
ctx: Context<{ linkmanId: string; existCount: number }>,
) {
const { linkmanId, existCount } = ctx.data;
const messages = await Message.find(
{ to: linkmanId },
{
type: 1,
content: 1,
from: 1,
createTime: 1,
deleted: 1,
},
{
sort: { createTime: -1 },
limit: EachFetchMessagesCount + existCount,
},
).populate('from', { username: 1, avatar: 1, tag: 1 });
await handleInviteV2Messages(messages);
const result = messages.slice(existCount).reverse();
return result;
}
/**
* 获取默认群组的历史消息
* @param ctx Context
*/
export async function getDefaultGroupHistoryMessages(
ctx: Context<{ existCount: number }>,
) {
const { existCount } = ctx.data;
const group = await Group.findOne({ isDefault: true });
if (!group) {
throw new AssertionError({ message: '默认群组不存在' });
}
const messages = await Message.find(
{ to: group._id },
{
type: 1,
content: 1,
from: 1,
createTime: 1,
deleted: 1,
},
{
sort: { createTime: -1 },
limit: EachFetchMessagesCount + existCount,
},
).populate('from', { username: 1, avatar: 1, tag: 1 });
await handleInviteV2Messages(messages);
const result = messages.slice(existCount).reverse();
return result;
}
/**
* 删除消息, 需要管理员权限
*/
export async function deleteMessage(ctx: Context<{ messageId: string }>) {
assert(
!client.disableDeleteMessage || ctx.socket.isAdmin,
'已禁止撤回消息',
);
const { messageId } = ctx.data;
assert(messageId, 'messageId不能为空');
const message = await Message.findOne({ _id: messageId });
if (!message) {
throw new AssertionError({ message: '消息不存在' });
}
assert(
ctx.socket.isAdmin ||
message.from.toString() === ctx.socket.user.toString(),
'只能撤回本人的消息',
);
if (ctx.socket.isAdmin) {
await Message.deleteOne({ _id: messageId });
} else {
message.deleted = true;
await message.save();
}
/**
* 广播删除消息通知, 区分群消息和私聊消息
*/
const messageName = 'deleteMessage';
const messageData = {
linkmanId: message.to.toString(),
messageId,
isAdmin: ctx.socket.isAdmin,
};
if (isValid(message.to)) {
// 群消息
ctx.socket.emit(message.to.toString(), messageName, messageData);
} else {
// 私聊消息
const targetUserId = message.to.replace(ctx.socket.user.toString(), '');
const targetSockets = await Socket.find({ user: targetUserId });
const targetSocketIdList =
targetSockets?.map((socket) => socket.id) || [];
if (targetSocketIdList) {
ctx.socket.emit(targetSocketIdList, messageName, messageData);
}
const selfSockets = await Socket.find({ user: ctx.socket.user });
const selfSocketIdList = selfSockets?.map((socket) => socket.id) || [];
if (selfSocketIdList) {
ctx.socket.emit(
selfSocketIdList.filter(
(socketId) => socketId !== ctx.socket.id,
),
messageName,
messageData,
);
}
}
return {
msg: 'ok',
};
}
@@ -0,0 +1,32 @@
import { AssertionError } from 'assert';
import User from '@fiora/database/mongoose/models/user';
import Notification from '@fiora/database/mongoose/models/notification';
export async function setNotificationToken(ctx: Context<{ token: string }>) {
const { token } = ctx.data;
const user = await User.findOne({ _id: ctx.socket.user });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
const notification = await Notification.findOne({ token: ctx.data.token });
if (notification) {
notification.user = user;
await notification.save();
} else {
await Notification.create({
user,
token,
});
const existNotifications = await Notification.find({ user });
if (existNotifications.length > 3) {
await Notification.deleteOne({ _id: existNotifications[0]._id });
}
}
return {
isOK: true,
};
}
+356
View File
@@ -0,0 +1,356 @@
import fs from 'fs';
import path from 'path';
import axios from 'axios';
import assert, { AssertionError } from 'assert';
import { promisify } from 'util';
import RegexEscape from 'regex-escape';
import OSS, { STS } from 'ali-oss';
import config from '@fiora/config/server';
import logger from '@fiora/utils/logger';
import User from '@fiora/database/mongoose/models/user';
import Group from '@fiora/database/mongoose/models/group';
import Socket from '@fiora/database/mongoose/models/socket';
import {
getAllSealIp,
getAllSealUser,
getSealIpKey,
getSealUserKey,
DisableSendMessageKey,
DisableNewUserSendMessageKey,
Redis,
} from '@fiora/database/redis/initRedis';
/** 百度语言合成token */
let baiduToken = '';
/** 最后一次获取token的时间 */
let lastBaiduTokenTime = Date.now();
/**
* 搜索用户和群组
* @param ctx Context
*/
export async function search(ctx: Context<{ keywords: string }>) {
const keywords = ctx.data.keywords?.trim() || '';
if (keywords === '') {
return {
users: [],
groups: [],
};
}
const escapedKeywords = RegexEscape(keywords);
const users = await User.find(
{ username: { $regex: escapedKeywords } },
{ avatar: 1, username: 1 },
);
const groups = await Group.find(
{ name: { $regex: escapedKeywords } },
{ avatar: 1, name: 1, members: 1 },
);
return {
users,
groups: groups.map((group) => ({
_id: group._id,
avatar: group.avatar,
name: group.name,
members: group.members.length,
})),
};
}
/**
* 搜索表情包, 爬其它站资源
* @param ctx Context
*/
export async function searchExpression(
ctx: Context<{ keywords: string; limit?: number }>,
) {
const { keywords, limit = Infinity } = ctx.data;
if (keywords === '') {
return [];
}
const res = await axios({
method: 'get',
url: `https://pic.sogou.com/pics/json.jsp?query=${encodeURIComponent(
`${keywords} 表情`,
)}&st=5&start=0&xml_len=60&callback=callback&reqFrom=wap_result&`,
headers: {
accept: '*/*',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7',
'cache-control': 'no-cache',
pragma: 'no-cache',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'same-origin',
referrer: `https://pic.sogou.com/pic/emo/searchList.jsp?statref=search_form&uID=hTHHybkSPt37C46z&spver=0&rcer=&keyword=${encodeURIComponent(
keywords,
)}`,
referrerPolicy: 'no-referrer-when-downgrade',
'user-agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
},
});
assert(res.status === 200, '搜索表情包失败, 请重试');
try {
const parseDataResult = res.data.match(/callback\((.+)\)/);
const data = JSON.parse(`${parseDataResult[1]}`);
type Image = {
locImageLink: string;
width: number;
height: number;
};
const images = data.items as Image[];
return images
.map(({ locImageLink, width, height }) => ({
image: locImageLink,
width,
height,
}))
.filter((image, index) =>
limit === Infinity ? true : index < limit,
);
} catch (err) {
assert(false, '搜索表情包失败, 数据解析异常');
}
return [];
}
/**
* 获取百度语言合成token
*/
export async function getBaiduToken() {
if (baiduToken && Date.now() < lastBaiduTokenTime) {
return { token: baiduToken };
}
const res = await axios.get(
'https://openapi.baidu.com/oauth/2.0/token?grant_type=client_credentials&client_id=pw152BzvaSZVwrUf3Z2OHXM6&client_secret=fa273cc704b080e85ad61719abbf7794',
);
assert(res.status === 200, '请求百度token失败');
baiduToken = res.data.access_token;
lastBaiduTokenTime =
Date.now() + (res.data.expires_in - 60 * 60 * 24) * 1000;
return { token: baiduToken };
}
/**
* 封禁用户, 需要管理员权限
* @param ctx Context
*/
export async function sealUser(ctx: Context<{ username: string }>) {
const { username } = ctx.data;
assert(username !== '', 'username不能为空');
const user = await User.findOne({ username });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
const userId = user._id.toString();
const isSealUser = await Redis.has(getSealUserKey(userId));
assert(!isSealUser, '用户已在封禁名单');
await Redis.set(getSealUserKey(userId), userId, Redis.Minute * 10);
return {
msg: 'ok',
};
}
/**
* 获取封禁列表, 包含用户封禁和ip封禁, 需要管理员权限
*/
export async function getSealList() {
const sealUserList = await getAllSealUser();
const sealIpList = await getAllSealIp();
const users = await User.find({ _id: { $in: sealUserList } });
const result = {
users: users.map((user) => user.username),
ips: sealIpList,
};
return result;
}
const CantSealLocalIp = '不能封禁内网ip';
const CantSealSelf = '闲的没事封自己干啥';
const IpInSealList = 'ip已在封禁名单';
/**
* 封禁 ip 地址, 需要管理员权限
*/
export async function sealIp(ctx: Context<{ ip: string }>) {
const { ip } = ctx.data;
assert(ip !== '::1' && ip !== '127.0.0.1', CantSealLocalIp);
assert(ip !== ctx.socket.ip, CantSealSelf);
const isSealIp = await Redis.has(getSealIpKey(ip));
assert(!isSealIp, IpInSealList);
await Redis.set(getSealIpKey(ip), ip, Redis.Hour * 6);
return {
msg: 'ok',
};
}
/**
* 封禁指定用户的所有在线 ip 地址, 需要管理员权限
*/
export async function sealUserOnlineIp(ctx: Context<{ userId: string }>) {
const { userId } = ctx.data;
const user = await User.findOne({ _id: userId });
assert(user, '用户不存在');
const sockets = await Socket.find({ user: userId });
const ipList = [
...sockets.map((socket) => socket.ip),
user.lastLoginIp,
].filter(
(ip) =>
ip !== '' &&
ip !== '::1' &&
ip !== '127.0.0.1' &&
ip !== ctx.socket.ip,
);
// 如果全部 ip 都已经封禁过了, 则直接提示
const isSealIpList = await Promise.all(
ipList.map((ip) => Redis.has(getSealIpKey(ip))),
);
assert(!isSealIpList.every((isSealIp) => isSealIp), IpInSealList);
await Promise.all(
ipList.map(async (ip) => {
await Redis.set(getSealIpKey(ip), ip, Redis.Hour * 6);
}),
);
return {
msg: 'ok',
};
}
type STSResult = {
enable: boolean;
AccessKeyId: string;
AccessKeySecret: string;
bucket: string;
region: string;
SecurityToken: string;
endpoint: string;
};
// eslint-disable-next-line consistent-return
export async function getSTS(): Promise<STSResult> {
if (!config.aliyunOSS.enable) {
// @ts-ignore
return {
enable: false,
};
}
const sts = new STS({
accessKeyId: config.aliyunOSS.accessKeyId,
accessKeySecret: config.aliyunOSS.accessKeySecret,
});
try {
const result = await sts.assumeRole(
config.aliyunOSS.roleArn,
undefined,
undefined,
'fiora-uploader',
);
// @ts-ignore
return {
enable: true,
region: config.aliyunOSS.region,
bucket: config.aliyunOSS.bucket,
endpoint: config.aliyunOSS.endpoint,
...result.credentials,
};
} catch (err) {
const typedErr = err as Error;
assert.fail(`获取 STS 失败 - ${typedErr.message}`);
}
}
export async function uploadFile(
ctx: Context<{ fileName: string; file: any; isBase64?: boolean }>,
) {
try {
if (config.aliyunOSS.enable) {
const sts = await getSTS();
const client = new OSS({
accessKeyId: sts.AccessKeyId,
accessKeySecret: sts.AccessKeySecret,
bucket: sts.bucket,
region: sts.region,
stsToken: sts.SecurityToken,
});
const result = await client.put(
ctx.data.fileName,
ctx.data.isBase64
? Buffer.from(ctx.data.file, 'base64')
: ctx.data.file,
);
if (result.res.status === 200) {
return {
url: `//${config.aliyunOSS.endpoint}/${result.name}`,
};
}
throw Error('上传阿里云OSS失败');
}
const [directory, fileName] = ctx.data.fileName.split('/');
const filePath = path.resolve('__dirname', '../public', directory);
const isExists = await promisify(fs.exists)(filePath);
if (!isExists) {
await promisify(fs.mkdir)(filePath);
}
await promisify(fs.writeFile)(
path.resolve(filePath, fileName),
ctx.data.file,
);
return {
url: `/${ctx.data.fileName}`,
};
} catch (err) {
const typedErr = err as Error;
logger.error('[uploadFile]', typedErr.message);
return `上传文件失败:${typedErr.message}`;
}
}
export async function toggleSendMessage(ctx: Context<{ enable: boolean }>) {
const { enable } = ctx.data;
await Redis.set(DisableSendMessageKey, (!enable).toString());
return {
msg: 'ok',
};
}
export async function toggleNewUserSendMessage(
ctx: Context<{ enable: boolean }>,
) {
const { enable } = ctx.data;
await Redis.set(DisableNewUserSendMessageKey, (!enable).toString());
return {
msg: 'ok',
};
}
export async function getSystemConfig() {
return {
disableSendMessage: (await Redis.get(DisableSendMessageKey)) === 'true',
disableNewUserSendMessage:
(await Redis.get(DisableNewUserSendMessageKey)) === 'true',
};
}
+600
View File
@@ -0,0 +1,600 @@
import bcrypt from 'bcryptjs';
import assert, { AssertionError } from 'assert';
import jwt from 'jwt-simple';
import { Types } from '@fiora/database/mongoose';
import config from '@fiora/config/server';
import getRandomAvatar from '@fiora/utils/getRandomAvatar';
import { SALT_ROUNDS } from '@fiora/utils/const';
import User, { UserDocument } from '@fiora/database/mongoose/models/user';
import Group, { GroupDocument } from '@fiora/database/mongoose/models/group';
import Friend, { FriendDocument } from '@fiora/database/mongoose/models/friend';
import Socket from '@fiora/database/mongoose/models/socket';
import Message, {
handleInviteV2Messages,
} from '@fiora/database/mongoose/models/message';
import Notification from '@fiora/database/mongoose/models/notification';
import {
getNewRegisteredUserIpKey,
getNewUserKey,
Redis,
} from '@fiora/database/redis/initRedis';
const { isValid } = Types.ObjectId;
/** 一天时间 */
const OneDay = 1000 * 60 * 60 * 24;
interface Environment {
/** 客户端系统 */
os: string;
/** 客户端浏览器 */
browser: string;
/** 客户端环境信息 */
environment: string;
}
/**
* 生成jwt token
* @param user 用户
* @param environment 客户端环境信息
*/
function generateToken(user: string, environment: string) {
return jwt.encode(
{
user,
environment,
expires: Date.now() + config.tokenExpiresTime,
},
config.jwtSecret,
);
}
/**
* 处理注册时间不满24小时的用户
* @param user 用户
*/
async function handleNewUser(user: UserDocument, ip = '') {
// 将用户添加到新用户列表, 24小时后删除
if (Date.now() - user.createTime.getTime() < OneDay) {
const userId = user._id.toString();
await Redis.set(getNewUserKey(userId), userId, Redis.Day);
if (ip) {
const registeredCount = await Redis.get(
getNewRegisteredUserIpKey(ip),
);
await Redis.set(
getNewRegisteredUserIpKey(ip),
(parseInt(registeredCount || '0', 10) + 1).toString(),
Redis.Day,
);
}
}
}
async function getUserNotificationTokens(user: UserDocument) {
const notifications = (await Notification.find({ user })) || [];
return notifications.map(({ token }) => token);
}
/**
* 注册新用户
* @param ctx Context
*/
export async function register(
ctx: Context<{ username: string; password: string } & Environment>,
) {
assert(!config.disableRegister, '注册功能已被禁用, 请联系管理员开通账号');
const { username, password, os, browser, environment } = ctx.data;
assert(username, '用户名不能为空');
assert(password, '密码不能为空');
const user = await User.findOne({ username });
assert(!user, '该用户名已存在');
const registeredCountWithin24Hours = await Redis.get(
getNewRegisteredUserIpKey(ctx.socket.ip),
);
assert(parseInt(registeredCountWithin24Hours || '0', 10) < 3, '系统错误');
const defaultGroup = await Group.findOne({ isDefault: true });
if (!defaultGroup) {
// TODO: refactor when node types support "Assertion Functions" https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
throw new AssertionError({ message: '默认群组不存在' });
}
const salt = await bcrypt.genSalt(SALT_ROUNDS);
const hash = await bcrypt.hash(password, salt);
let newUser = null;
try {
newUser = await User.create({
username,
salt,
password: hash,
avatar: getRandomAvatar(),
lastLoginIp: ctx.socket.ip,
} as UserDocument);
} catch (err) {
if ((err as Error).name === 'ValidationError') {
return '用户名包含不支持的字符或者长度超过限制';
}
throw err;
}
await handleNewUser(newUser, ctx.socket.ip);
if (!defaultGroup.creator) {
defaultGroup.creator = newUser._id;
}
defaultGroup.members.push(newUser._id);
await defaultGroup.save();
const token = generateToken(newUser._id.toString(), environment);
ctx.socket.user = newUser._id.toString();
await Socket.updateOne(
{ id: ctx.socket.id },
{
user: newUser._id,
os,
browser,
environment,
},
);
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,
isAdmin: false,
notificationTokens: [],
};
}
/**
* 账密登录
* @param ctx Context
*/
export async function login(
ctx: Context<{ username: string; password: string } & Environment>,
) {
const { username, password, os, browser, environment } = ctx.data;
assert(username, '用户名不能为空');
assert(password, '密码不能为空');
const user = await User.findOne({ username });
if (!user) {
throw new AssertionError({ message: '该用户不存在' });
}
const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, '密码错误');
await handleNewUser(user);
user.lastLoginTime = new Date();
user.lastLoginIp = ctx.socket.ip;
await user.save();
const groups = await Group.find(
{ members: user._id },
{
_id: 1,
name: 1,
avatar: 1,
creator: 1,
createTime: 1,
},
);
groups.forEach((group) => {
ctx.socket.join(group._id.toString());
});
const friends = await Friend.find({ from: user._id }).populate('to', {
avatar: 1,
username: 1,
});
const token = generateToken(user._id.toString(), environment);
ctx.socket.user = user._id.toString();
await Socket.updateOne(
{ id: ctx.socket.id },
{
user: user._id,
os,
browser,
environment,
},
);
const notificationTokens = await getUserNotificationTokens(user);
return {
_id: user._id,
avatar: user.avatar,
username: user.username,
tag: user.tag,
groups,
friends,
token,
isAdmin: config.administrator.includes(user._id.toString()),
notificationTokens,
};
}
/**
* token登录
* @param ctx Context
*/
export async function loginByToken(
ctx: Context<{ token: string } & Environment>,
) {
const { token, os, browser, environment } = ctx.data;
assert(token, 'token不能为空');
let payload = null;
try {
payload = jwt.decode(token, config.jwtSecret);
} catch (err) {
return '非法token';
}
assert(Date.now() < payload.expires, 'token已过期');
assert.equal(environment, payload.environment, '非法登录');
const user = await User.findOne(
{ _id: payload.user },
{
_id: 1,
avatar: 1,
username: 1,
tag: 1,
createTime: 1,
},
);
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
await handleNewUser(user);
user.lastLoginTime = new Date();
user.lastLoginIp = ctx.socket.ip;
await user.save();
const groups = await Group.find(
{ members: user._id },
{
_id: 1,
name: 1,
avatar: 1,
creator: 1,
createTime: 1,
},
);
groups.forEach((group: GroupDocument) => {
ctx.socket.join(group._id.toString());
});
const friends = await Friend.find({ from: user._id }).populate('to', {
avatar: 1,
username: 1,
});
ctx.socket.user = user._id.toString();
await Socket.updateOne(
{ id: ctx.socket.id },
{
user: user._id,
os,
browser,
environment,
},
);
const notificationTokens = await getUserNotificationTokens(user);
return {
_id: user._id,
avatar: user.avatar,
username: user.username,
tag: user.tag,
groups,
friends,
isAdmin: config.administrator.includes(user._id.toString()),
notificationTokens,
};
}
/**
* 游客登录, 只能获取默认群组信息
* @param ctx Context
*/
export async function guest(ctx: Context<Environment>) {
const { os, browser, environment } = ctx.data;
await Socket.updateOne(
{ id: ctx.socket.id },
{
os,
browser,
environment,
},
);
const group = await Group.findOne(
{ isDefault: true },
{
_id: 1,
name: 1,
avatar: 1,
createTime: 1,
creator: 1,
},
);
if (!group) {
throw new AssertionError({ message: '默认群组不存在' });
}
ctx.socket.join(group._id.toString());
const messages = await Message.find(
{ to: group._id },
{
type: 1,
content: 1,
from: 1,
createTime: 1,
deleted: 1,
},
{ sort: { createTime: -1 }, limit: 15 },
).populate('from', { username: 1, avatar: 1 });
await handleInviteV2Messages(messages);
messages.reverse();
return { messages, ...group.toObject() };
}
/**
* 修改用户头像
* @param ctx Context
*/
export async function changeAvatar(ctx: Context<{ avatar: string }>) {
const { avatar } = ctx.data;
assert(avatar, '新头像链接不能为空');
await User.updateOne(
{ _id: ctx.socket.user },
{
avatar,
},
);
return {};
}
/**
* 添加好友, 单向添加
* @param ctx Context
*/
export async function addFriend(ctx: Context<{ userId: string }>) {
const { userId } = ctx.data;
assert(isValid(userId), '无效的用户ID');
assert(ctx.socket.user !== userId, '不能添加自己为好友');
const user = await User.findOne({ _id: userId });
if (!user) {
throw new AssertionError({ message: '添加好友失败, 用户不存在' });
}
const friend = await Friend.find({ from: ctx.socket.user, to: user._id });
assert(friend.length === 0, '你们已经是好友了');
const newFriend = await Friend.create({
from: ctx.socket.user as string,
to: user._id,
} as FriendDocument);
return {
_id: user._id,
username: user.username,
avatar: user.avatar,
from: newFriend.from,
to: newFriend.to,
};
}
/**
* 删除好友, 单向删除
* @param ctx Context
*/
export async function deleteFriend(ctx: Context<{ userId: string }>) {
const { userId } = ctx.data;
assert(isValid(userId), '无效的用户ID');
const user = await User.findOne({ _id: userId });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
await Friend.deleteOne({ from: ctx.socket.user, to: user._id });
return {};
}
/**
* 修改用户密码
* @param ctx Context
*/
export async function changePassword(
ctx: Context<{ oldPassword: string; newPassword: string }>,
) {
const { oldPassword, newPassword } = ctx.data;
assert(newPassword, '新密码不能为空');
assert(oldPassword !== newPassword, '新密码不能与旧密码相同');
const user = await User.findOne({ _id: ctx.socket.user });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
const isPasswordCorrect = bcrypt.compareSync(oldPassword, user.password);
assert(isPasswordCorrect, '旧密码不正确');
const salt = await bcrypt.genSalt(SALT_ROUNDS);
const hash = await bcrypt.hash(newPassword, salt);
user.password = hash;
await user.save();
return {
msg: 'ok',
};
}
/**
* 修改用户名
* @param ctx Context
*/
export async function changeUsername(ctx: Context<{ username: string }>) {
const { username } = ctx.data;
assert(username, '新用户名不能为空');
const user = await User.findOne({ username });
assert(!user, '该用户名已存在, 换一个试试吧');
const self = await User.findOne({ _id: ctx.socket.user });
if (!self) {
throw new AssertionError({ message: '用户不存在' });
}
self.username = username;
await self.save();
return {
msg: 'ok',
};
}
/**
* 重置用户密码, 需要管理员权限
* @param ctx Context
*/
export async function resetUserPassword(ctx: Context<{ username: string }>) {
const { username } = ctx.data;
assert(username !== '', 'username不能为空');
const user = await User.findOne({ username });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
const newPassword = 'helloworld';
const salt = await bcrypt.genSalt(SALT_ROUNDS);
const hash = await bcrypt.hash(newPassword, salt);
user.salt = salt;
user.password = hash;
await user.save();
return {
newPassword,
};
}
/**
* 更新用户标签, 需要管理员权限
* @param ctx Context
*/
export async function setUserTag(
ctx: Context<{ username: string; tag: string }>,
) {
const { username, tag } = ctx.data;
assert(username !== '', 'username不能为空');
assert(tag !== '', 'tag不能为空');
assert(
/^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]){1,5}$/.test(tag),
'标签不符合要求, 允许5个汉字或者10个字母',
);
const user = await User.findOne({ username });
if (!user) {
throw new AssertionError({ message: '用户不存在' });
}
user.tag = tag;
await user.save();
const sockets = await Socket.find({ user: user._id });
const socketIdList = sockets.map((socket) => socket.id);
if (socketIdList.length) {
ctx.socket.emit(socketIdList, 'changeTag', user.tag);
}
return {
msg: 'ok',
};
}
/**
* 获取指定在线用户 ip
*/
export async function getUserIps(
ctx: Context<{ userId: string }>,
): Promise<string[]> {
const { userId } = ctx.data;
assert(userId, 'userId不能为空');
assert(isValid(userId), '不合法的userId');
const sockets = await Socket.find({ user: userId });
const ipList = sockets.map((socket) => socket.ip) || [];
return Array.from(new Set(ipList));
}
const UserOnlineStatusCacheExpireTime = 1000 * 60;
function getUserOnlineStatusWrapper() {
const cache: Record<
string,
{
value: boolean;
expireTime: number;
}
> = {};
return async function getUserOnlineStatus(
ctx: Context<{ userId: string }>,
) {
const { userId } = ctx.data;
assert(userId, 'userId不能为空');
assert(isValid(userId), '不合法的userId');
if (cache[userId] && cache[userId].expireTime > Date.now()) {
return {
isOnline: cache[userId].value,
};
}
const sockets = await Socket.find({ user: userId });
const isOnline = sockets.length > 0;
cache[userId] = {
value: isOnline,
expireTime: Date.now() + UserOnlineStatusCacheExpireTime,
};
return {
isOnline,
};
};
}
export const getUserOnlineStatus = getUserOnlineStatusWrapper();
+1
View File
@@ -0,0 +1 @@
declare module 'regex-escape';
+31
View File
@@ -0,0 +1,31 @@
declare interface Context<T> {
data: T;
socket: {
id: string;
ip: string;
user: string;
isAdmin: boolean;
join: (room: string) => void;
leave: (room: string) => void;
emit: (target: string[] | string, event: string, data: any) => void;
};
}
declare interface RouteHandler {
(ctx: Context<any>): string | any;
}
declare type Routes = Record<string, RouteHandler | null>;
declare type MiddlewareArgs = Array<any>;
declare type MiddlewareNext = () => void;
declare interface SendMessageData {
/** 消息目标 */
to: string;
/** 消息类型 */
type: string;
/** 消息内容 */
content: string;
}