chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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;
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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 don’t 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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'regex-escape';
|
||||
+31
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user