Files
Hydro/packages/hydrooj/src/handler/home.ts
T
2026-08-09 07:28:40 +08:00

664 lines
27 KiB
TypeScript

import path from 'path';
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import * as yaml from 'js-yaml';
import { pick } from 'lodash';
import { Binary, ObjectId } from 'mongodb';
import { UAParser } from 'ua-parser-js';
import { Context } from '../context';
import {
AuthOperationError, BadRequestError, BlacklistedError, DomainAlreadyExistsError, InvalidTokenError,
NotFoundError, PermissionError, UserAlreadyExistError,
UserNotFoundError, ValidationError, VerifyPasswordError,
} from '../error';
import { DomainDoc, Setting } from '../interface';
import avatar, { validate } from '../lib/avatar';
import * as mail from '../lib/mail';
import { verifyTFA } from '../lib/verifyTFA';
import BlackListModel from '../model/blacklist';
import { PERM, PRIV } from '../model/builtin';
import * as contest from '../model/contest';
import * as discussion from '../model/discussion';
import domain from '../model/domain';
import message from '../model/message';
import ProblemModel from '../model/problem';
import * as setting from '../model/setting';
import storage from '../model/storage';
import system from '../model/system';
import token from '../model/token';
import * as training from '../model/training';
import user from '../model/user';
import {
Handler, param, query, requireSudo, Types,
} from '../service/server';
import { camelCase, md5 } from '../utils';
export class HomeHandler extends Handler {
uids = new Set<number>();
collectUser(uids: number[]) {
for (const uid of uids) this.uids.add(uid);
}
async getHomework(domainId: string, limit = 5) {
if (!this.user.hasPerm(PERM.PERM_VIEW_HOMEWORK)) return [[], {}];
const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_HOMEWORK) ? undefined : this.user._id))
.map((i) => i.name);
const tdocs = await contest.getMulti(domainId, {
rule: 'homework',
...this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_HOMEWORK)
? {}
: {
$or: [
{ maintainer: this.user._id },
{ owner: this.user._id },
{ assign: { $in: groups } },
{ assign: { $size: 0 } },
],
},
}).sort({
penaltySince: -1, endAt: -1, beginAt: -1, _id: -1,
}).limit(limit).toArray();
const tsdict = await contest.getListStatus(
domainId, this.user._id, tdocs.map((tdoc) => tdoc.docId),
);
return [tdocs, tsdict];
}
async getContest(domainId: string, limit = 10) {
if (!this.user.hasPerm(PERM.PERM_VIEW_CONTEST)) return [[], {}];
const rules = Object.keys(contest.RULES).filter((i) => !contest.RULES[i].hidden);
const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) ? undefined : this.user._id))
.map((i) => i.name);
const q = {
rule: { $in: rules },
...this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST)
? {}
: {
$or: [
{ maintainer: this.user._id },
{ owner: this.user._id },
{ assign: { $in: groups } },
{ assign: { $size: 0 } },
],
},
};
const tdocs = await contest.getMulti(domainId, q).sort({ endAt: -1, beginAt: -1, _id: -1 })
.limit(limit).toArray();
const tsdict = await contest.getListStatus(
domainId, this.user._id, tdocs.map((tdoc) => tdoc.docId),
);
return [tdocs, tsdict];
}
async getTraining(domainId: string, limit = 10) {
if (!this.user.hasPerm(PERM.PERM_VIEW_TRAINING)) return [[], {}];
const tdocs = await training.getMulti(domainId)
.sort({ pin: -1, _id: 1 }).limit(limit).toArray();
const tsdict = await training.getListStatus(
domainId, this.user._id, tdocs.map((tdoc) => tdoc.docId),
);
return [tdocs, tsdict];
}
async getDiscussion(domainId: string, limit = 20) {
if (!this.user.hasPerm(PERM.PERM_VIEW_DISCUSSION)) return [[], {}];
const ddocs = await discussion.getMulti(domainId).limit(limit).toArray();
const vndict = await discussion.getListVnodes(domainId, ddocs, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN), this.user.group);
this.collectUser(ddocs.map((ddoc) => ddoc.owner));
return [ddocs, vndict];
}
async getRanking(domainId: string, limit = 50) {
if (!this.user.hasPerm(PERM.PERM_VIEW_RANKING)) return [];
const dudocs = await domain.getMultiUserInDomain(domainId, { uid: { $gt: 1 }, rp: { $gt: 0 } })
.sort({ rp: -1 }).project({ uid: 1 }).limit(limit).toArray();
const uids = dudocs.map((dudoc) => dudoc.uid);
this.collectUser(uids);
return uids;
}
async getStarredProblems(domainId: string, limit = 50) {
if (!this.user.hasPerm(PERM.PERM_VIEW_PROBLEM)) return [[], {}];
const psdocs = await ProblemModel.getMultiStatus(domainId, { uid: this.user._id, star: true })
.sort('_id', 1).limit(limit).toArray();
const pdict = await ProblemModel.getList(
domainId, psdocs.map((pdoc) => pdoc.docId),
this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, false,
);
const pdocs = Object.keys(pdict).filter((i) => +i).map((i) => pdict[i]);
return [pdocs];
}
async getRecentProblems(domainId: string, limit = 10) {
if (!this.user.hasPerm(PERM.PERM_VIEW_PROBLEM)) return [[], {}];
const pdocs = await ProblemModel.getMulti(domainId, { hidden: false })
.sort({ _id: -1 }).limit(limit).toArray();
const psdict = this.user.hasPriv(PRIV.PRIV_USER_PROFILE)
? await ProblemModel.getListStatus(domainId, this.user._id, pdocs.map((pdoc) => pdoc.docId))
: {};
return [pdocs, psdict];
}
getDiscussionNodes(domainId: string) {
return discussion.getNodes(domainId);
}
async get({ domainId }) {
const homepageConfig = this.ctx.setting.get('hydrooj.homepage');
const info = yaml.load(homepageConfig) as any;
const contents = [];
for (const column of info) {
const tasks = [];
for (const name in column) {
if (name === 'width') continue;
const func = `get${camelCase(name).replace(/^[a-z]/, (i) => i.toUpperCase())}`;
if (!this[func]) tasks.push([name, column[name]]);
else {
tasks.push(
this[func](domainId, column[name])
.then((res) => [name, res])
.catch((err) => ['error', err.message]),
);
}
}
contents.push({
width: column.width,
// eslint-disable-next-line no-await-in-loop
sections: await Promise.all(tasks),
});
}
const udict = await user.getList(domainId, Array.from(this.uids));
this.response.template = 'main.html';
this.response.body = {
contents,
udict,
domain: this.domain,
};
}
}
class HomeSecurityHandler extends Handler {
@requireSudo
async get() {
// TODO(iceboy): pagination? or limit session count for uid?
const sessions = await token.getSessionListByUid(this.user._id);
for (const session of sessions) {
session.isCurrent = session._id === this.session._id;
session._id = md5(session._id);
const ua = session.updateUa || session.createUa;
if (ua) session.updateUaInfo = UAParser(ua);
session.updateGeoip = this.ctx.get('geoip')?.lookup?.(
session.updateIp || session.createIp,
this.translate('geoip_locale'),
);
}
const relations = await this.ctx.oauth.list(this.user._id);
this.response.template = 'home_security.html';
this.response.body = {
sudoUid: this.session.sudoUid || null,
sessions,
authenticators: this.user._authenticators.map((c) => pick(c, [
'credentialID', 'name', 'credentialType', 'credentialDeviceType',
'authenticatorAttachment', 'regat', 'fmt',
])),
geoipProvider: this.ctx.get('geoip')?.provider,
relations,
loginMethods: this.loginMethods,
};
}
@requireSudo
@param('current', Types.String)
@param('password', Types.Password)
@param('verifyPassword', Types.Password)
async postChangePassword(domainId: string, current: string, password: string, verify: string) {
if (password !== verify) throw new VerifyPasswordError();
if (this.session.sudoUid) {
const udoc = await user.getById(domainId, this.session.sudoUid);
if (!udoc) throw new UserNotFoundError(this.session.sudoUid);
await udoc.checkPassword(current);
} else await this.user.checkPassword(current);
await user.setPassword(this.user._id, password);
await token.delByUid(this.user._id);
this.response.redirect = this.url('user_login');
}
@requireSudo
@param('password', Types.Password)
@param('mail', Types.Email)
async postChangeMail(domainId: string, current: string, email: string) {
const mailDomain = email.split('@')[1];
if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain);
if (this.session.sudoUid) {
const udoc = await user.getById(domainId, this.session.sudoUid);
if (!udoc) throw new UserNotFoundError(this.session.sudoUid);
await udoc.checkPassword(current);
} else await this.user.checkPassword(current);
const udoc = await user.getByEmail(domainId, email);
if (udoc) throw new UserAlreadyExistError(email);
await this.limitRate('send_mail', 3600, 30);
const [code] = await token.add(
token.TYPE_CHANGEMAIL,
system.get('session.unsaved_expire_seconds'),
{ uid: this.user._id, email },
);
const prefix = (this.domain.host || [])[0] || system.get('server.url');
const m = await this.renderHTML('user_changemail_mail.html', {
path: `/home/changeMail/${code}`,
uname: this.user.uname,
url_prefix: prefix.endsWith('/') ? prefix.slice(0, -1) : prefix,
});
await mail.sendMail(email, 'Change Email', 'user_changemail_mail', m.toString());
this.response.template = 'user_changemail_mail_sent.html';
}
@param('platform', Types.String)
async postLinkAccount({ }, platform: string) {
if (!this.ctx.oauth.providers[platform]) throw new ValidationError('platform');
this.session.oauthBind = platform;
await this.ctx.oauth.providers[platform].get.call(this);
}
@param('platform', Types.String)
async postUnlinkAccount({ }, platform: string) {
if (!this.ctx.oauth.providers[platform]) throw new ValidationError('platform');
await this.ctx.oauth.unbind(platform, this.user._id);
this.back();
}
@param('tokenDigest', Types.String)
async postDeleteToken({ }, tokenDigest: string) {
const sessions = await token.getSessionListByUid(this.user._id);
for (const session of sessions) {
if (tokenDigest === md5(session._id)) {
// eslint-disable-next-line no-await-in-loop
await token.del(session._id, token.TYPE_SESSION);
return this.back();
}
}
throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_SESSION], tokenDigest);
}
async postDeleteAllTokens() {
await token.delByUid(this.user._id, token.TYPE_SESSION);
this.response.redirect = this.url('user_login');
}
@requireSudo
@param('code', Types.String)
@param('secret', Types.String)
async postEnableTfa({ }, code: string, secret: string) {
if (this.user._tfa) throw new AuthOperationError('2FA', 'enabled');
if (!verifyTFA(secret, code)) throw new InvalidTokenError('2FA');
await user.setById(this.user._id, { tfa: secret });
this.back();
}
getAuthnHost() {
return system.get('authn.host') && this.request.hostname.includes(system.get('authn.host'))
? system.get('authn.host') : this.request.hostname;
}
@requireSudo
@param('type', Types.Range(['cross-platform', 'platform']))
async postRegister({ }, type: 'cross-platform' | 'platform') {
const options = await generateRegistrationOptions({
rpName: system.get('server.name'),
rpID: this.getAuthnHost(),
userID: isoUint8Array.fromUTF8String(this.user._id.toString()),
userDisplayName: this.user.uname,
userName: `${this.user.uname}(${this.user.mail})`,
attestationType: 'direct',
excludeCredentials: this.user._authenticators.map((c) => ({
id: isoBase64URL.fromBuffer(new Uint8Array(c.credentialID.buffer)),
type: 'public-key',
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
authenticatorAttachment: type,
},
});
this.session.webauthnVerify = options.challenge;
this.response.body.authOptions = options;
}
@requireSudo
@param('name', Types.String)
async postEnableAuthn({ }, name: string) {
if (!this.session.webauthnVerify) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
const verification = await verifyRegistrationResponse({
response: this.args.result,
expectedChallenge: this.session.webauthnVerify,
expectedOrigin: this.request.headers.origin,
expectedRPID: this.getAuthnHost(),
}).catch(() => { throw new ValidationError('verify'); });
if (!verification.verified) throw new ValidationError('verify');
const info = verification.registrationInfo;
const id = isoBase64URL.toBuffer(info.credential.id);
if (this.user._authenticators.find((c) => c.credentialID.buffer.toString() === id.toString())) throw new ValidationError('authenticator');
this.user._authenticators.push({
...info,
counter: info.credential.counter,
credentialID: new Binary(id),
credentialPublicKey: new Binary(Buffer.from(info.credential.publicKey)),
attestationObject: new Binary(Buffer.from(info.attestationObject)),
name,
regat: Date.now(),
authenticatorAttachment: this.args.result.authenticatorAttachment || 'cross-platform',
});
await user.setById(this.user._id, { authenticators: this.user._authenticators });
this.back();
}
@requireSudo
@param('id', Types.String)
async postDisableAuthn({ }, id: string) {
const authenticators = this.user._authenticators?.filter((c) => Buffer.from(c.credentialID.buffer).toString('base64') !== id);
if (this.user._authenticators?.length === authenticators?.length) throw new ValidationError('authenticator');
await user.setById(this.user._id, { authenticators });
this.back();
}
@requireSudo
async postDisableTfa() {
if (!this.user._tfa) throw new AuthOperationError('2FA', 'disabled');
await user.setById(this.user._id, undefined, { tfa: '' });
this.back();
}
}
function set(s: Setting, key: string, value: any) {
if (!s) return undefined;
if (s.family === 'setting_storage') return undefined;
if (s.flag & setting.FLAG_DISABLED) return undefined;
if ((s.flag & setting.FLAG_SECRET) && !value) return undefined;
if (s.validation && !s.validation(value)) throw new ValidationError(key);
if (s.type === 'boolean') {
if (value === 'on') return true;
return false;
}
if (s.type === 'number') {
if (!Number.isSafeInteger(+value)) throw new ValidationError(key);
return +value;
}
if (s.type === 'float') {
if (Number.isNaN(+value)) throw new ValidationError(key);
return +value;
}
if (value) {
if (['json', 'yaml', 'markdown', 'textarea'].includes(s.type)) {
if (!Types.Content[1](value)) throw new ValidationError(key);
}
if (s.type === 'text') {
if (!Types.ShortString[1](value)) throw new ValidationError(key);
}
}
if (s.subType === 'yaml') {
try {
yaml.load(value);
} catch (e) {
throw new ValidationError(key);
}
}
if (s.subType === 'json') {
try {
JSON.parse(value);
} catch (e) {
throw new ValidationError(key);
}
}
return value;
}
class HomeSettingsHandler extends Handler {
@param('category', Types.Range(['preference', 'account', 'domain']))
async get({ }, category: string) {
this.response.template = 'home_settings.html';
this.response.body = {
category,
page_name: `home_${category}`,
current: this.user,
};
if (category === 'preference') {
this.response.body.settings = setting.PREFERENCE_SETTINGS;
} else if (category === 'account') {
this.response.body.settings = setting.ACCOUNT_SETTINGS;
} else if (category === 'domain') {
this.response.body.settings = setting.DOMAIN_USER_SETTINGS;
} else throw new NotFoundError(category);
}
async post(args: any) {
const $set = {};
const booleanKeys = args.booleanKeys || {};
delete args.booleanKeys;
const setter = args.category === 'domain'
? (s) => domain.setUserInDomain(args.domainId, this.user._id, s)
: (s) => user.setById(this.user._id, s);
const settings = args.category === 'domain' ? setting.DOMAIN_USER_SETTINGS_BY_KEY : setting.SETTINGS_BY_KEY;
for (const key in args) {
const val = set(settings[key], key, args[key]);
if (val !== undefined) $set[key] = val;
}
for (const key in booleanKeys) if (!args[key]) $set[key] = false;
if (Object.keys($set).length) await setter($set);
if (args.viewLang && args.viewLang !== this.session.viewLang) this.session.viewLang = '';
this.back();
}
}
class HomeAvatarHandler extends Handler {
noCheckPermView = true;
@param('avatar', Types.String, true)
async post({ }, input: string) {
if (input) {
if (!validate(input)) throw new ValidationError('avatar');
await user.setById(this.user._id, { avatar: input });
} else if (this.request.files.file) {
const file = this.request.files.file;
if (file.size > 8 * 1024 * 1024) throw new ValidationError('file');
const ext = path.extname(file.originalFilename).toLowerCase();
if (!['.jpg', '.jpeg', '.png'].includes(ext)) throw new ValidationError('file');
await storage.put(`user/${this.user._id}/.avatar${ext}`, file.filepath, this.user._id);
// TODO: cached avatar
await user.setById(this.user._id, { avatar: `url:/file/${this.user._id}/.avatar${ext}` });
} else throw new ValidationError('avatar');
this.back();
}
}
class UserChangemailWithCodeHandler extends Handler {
@param('code', Types.String)
async get(domainId: string, code: string) {
const tdoc = await token.get(code, token.TYPE_CHANGEMAIL);
if (!tdoc || tdoc.uid !== this.user._id) {
throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_CHANGEMAIL], code);
}
const udoc = await user.getByEmail(domainId, tdoc.email);
if (udoc) throw new UserAlreadyExistError(tdoc.email);
await Promise.all([
user.setEmail(this.user._id, tdoc.email),
token.del(code, token.TYPE_CHANGEMAIL),
]);
this.response.redirect = this.url('home_security');
}
}
class HomeDomainHandler extends Handler {
@query('all', Types.Boolean)
async get({ }, all: boolean) {
let ddocs: DomainDoc[] = [];
const role: Record<string, string> = {};
if (!all) {
const dudict = await domain.getDictUserByDomainId(this.user._id);
const dids = Object.keys(dudict);
ddocs = await domain.getMulti({ _id: { $in: dids } }).toArray();
} else {
this.checkPriv(PRIV.PRIV_VIEW_ALL_DOMAIN);
ddocs = await domain.getMulti().toArray();
}
const canManage = {};
if (this.user.hasPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN)) {
for (const ddoc of ddocs) {
canManage[ddoc._id] = true;
role[ddoc._id] = 'root';
}
} else {
for (const ddoc of ddocs) {
// eslint-disable-next-line no-await-in-loop
const udoc = await user.getById(ddoc._id, this.user._id);
canManage[ddoc._id] = udoc.hasPerm(PERM.PERM_EDIT_DOMAIN);
role[ddoc._id] = udoc.role;
}
}
this.response.template = 'home_domain.html';
this.response.body = { ddocs, canManage, role };
}
@param('id', Types.DomainId)
@param('star', Types.Boolean)
async postStar({ }, id: string, star = false) {
if (star) {
const ddoc = await domain.get(id);
if (!ddoc) throw new NotFoundError(id);
await user.setById(this.user._id, { pinnedDomains: [...this.user.pinnedDomains, id] });
} else user.setById(this.user._id, { pinnedDomains: this.user.pinnedDomains.filter((i) => i !== id) });
this.back({ star });
}
@param('id', Types.DomainId)
async postLeave({ }, id: string) {
if (id === 'system') throw new BadRequestError();
const ddoc = await domain.get(id);
if (!ddoc) throw new NotFoundError(id);
await domain.setJoin(id, this.user._id, false);
this.back();
}
}
class HomeDomainCreateHandler extends Handler {
async get() {
this.response.template = 'domain_create.html';
}
@param('id', Types.DomainId)
@param('name', Types.Title)
@param('bulletin', Types.Content)
@param('avatar', Types.Content, true)
// eslint-disable-next-line ts/no-shadow
async post(_: string, id: string, name: string, bulletin: string, avatar: string) {
const doc = await domain.get(id);
if (doc) throw new DomainAlreadyExistsError(id);
avatar ||= this.user.avatar || `gravatar:${this.user.mail}`;
const domainId = await domain.add(id, this.user._id, name, bulletin);
// When this domain is deleted but previously added to user's list we shouldn't push it again
const push = !this.user.pinnedDomains?.includes(domainId);
await Promise.all([
domain.edit(domainId, { avatar }),
domain.setUserRole(domainId, this.user._id, 'root'),
push
? user.setById(this.user._id, undefined, undefined, { pinnedDomains: domainId })
: Promise.resolve(),
]);
this.response.redirect = this.url('domain_dashboard', { domainId });
this.response.body = { domainId };
}
}
class HomeMessagesHandler extends Handler {
async get() {
// TODO(iceboy): projection, pagination.
const messages = await message.getByUser(this.user._id);
const uids = new Set<number>([
...messages.map((mdoc) => mdoc.from),
...messages.flatMap((mdoc) => mdoc.to),
]);
const udict = await user.getList('system', Array.from(uids));
// TODO(twd2): improve here:
const parsed = {};
for (const m of messages) {
const raw = m.from === this.user._id ? m.to : m.from;
const targetArr = Array.isArray(raw) ? raw : [raw];
for (const target of targetArr) {
parsed[target] ||= {
_id: target,
udoc: { ...udict[target], avatarUrl: avatar(udict[target].avatar) },
messages: [],
};
parsed[target].messages.push(m);
}
}
await user.setById(this.user._id, { unreadMsg: 0 });
this.response.body = { messages: parsed };
this.response.template = 'home_messages.html';
}
@param('uid', Types.Int)
@param('content', Types.Content)
async postSend({ }, uid: number, content: string) {
this.checkPriv(PRIV.PRIV_SEND_MESSAGE);
const udoc = await user.getById('system', uid);
if (!udoc) throw new UserNotFoundError(uid);
if (udoc.avatar) udoc.avatarUrl = avatar(udoc.avatar);
const mdoc = await message.send(this.user._id, uid, content, message.FLAG_UNREAD);
this.back({ mdoc, udoc });
}
@param('messageId', Types.ObjectId)
async postDeleteMessage({ }, messageId: ObjectId) {
const msg = await message.get(messageId);
if (msg.from === this.user._id) await message.del(messageId);
else throw new PermissionError();
this.back();
}
}
export const inject = ['oauth'];
export function apply(ctx: Context) {
ctx.Route('homepage', '/', HomeHandler);
ctx.Route('home_security', '/home/security', HomeSecurityHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('user_changemail_with_code', '/home/changeMail/:code', UserChangemailWithCodeHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('home_settings', '/home/settings/:category', HomeSettingsHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('home_avatar', '/home/avatar', HomeAvatarHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('home_domain', '/home/domain', HomeDomainHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('home_domain_create', '/home/domain/create', HomeDomainCreateHandler, PRIV.PRIV_CREATE_DOMAIN);
ctx.Route('home_messages', '/home/messages', HomeMessagesHandler, PRIV.PRIV_USER_PROFILE);
async function notifyMessage(uid: number[], mdoc: any, h) {
const udoc = (await user.getById('system', mdoc.from))!;
return {
operation: 'event',
channels: uid.map((u) => `message:${u}`),
payload: { udoc: { ...udoc.serialize(h) as any, avatarUrl: avatar(udoc.avatar, 128) }, mdoc },
};
}
ctx.on('subscription/init', (h, privileged) => {
if (!privileged) return;
h.ctx.on('user/message', async (uid, mdoc) => {
h.send(await notifyMessage(uid, mdoc, h));
});
});
ctx.on('subscription/enable', (channel, h, privileged) => {
if (!channel.startsWith('message:') || privileged) return;
const uid = +channel.split(':')[1];
h.ctx.on('user/message', async (uids, mdoc) => {
if (!uids.includes(uid)) return;
h.send(await notifyMessage([uid], mdoc, h));
});
});
ctx.on('subscription/subscribe', (channel, udoc) => { // eslint-disable-line consistent-return
if (channel === 'message' && udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) {
return {
ok: true,
channel: `message:${udoc._id}`,
};
}
});
}