diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index b239ec8e6..8a9e979b7 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -439,6 +439,7 @@ Location: 位置 Login to Attend Contest: 登录后参加比赛 Login to Claim Homework: 登录后认领作业 Login to Create a Discussion: 登录后创建讨论 +Login to join training plan: 登录以参加训练计划 Login to Submit: 登录后递交 Login: 登录 Logout All Sessions: 注销所有会话 diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index e041003b9..3e92bd8bc 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.2.43", + "version": "3.3.0", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index df38054c6..4265ae984 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -3,7 +3,7 @@ import moment from 'moment-timezone'; import { ObjectID } from 'mongodb'; import { Time } from '@hydrooj/utils/lib/utils'; import { - ContestNotFoundError, ContestNotLiveError, InvalidTokenError, + ContestNotFoundError, ContestNotLiveError, ForbiddenError, InvalidTokenError, PermissionError, ValidationError, } from '../error'; import { Tdoc } from '../interface'; @@ -74,6 +74,18 @@ export class ContestListHandler extends Handler { } export class ContestDetailHandler extends Handler { + @param('tid', Types.ObjectID) + @param('page', Types.PositiveInt, true) + async prepare(domainId: string, tid: ObjectID) { + const tdoc = await contest.get(domainId, tid); + if (tdoc.assign) { + const groups = await user.listGroup(domainId, this.user._id); + if (!Set.intersection(tdoc.assign, groups.map((i) => i.name)).size) { + throw new ForbiddenError('You are not assigned.'); + } + } + } + @param('tid', Types.ObjectID) @param('page', Types.PositiveInt, true) async get(domainId: string, tid: ObjectID, page = 1) { @@ -238,9 +250,11 @@ export class ContestEditHandler extends Handler { @param('rated', Types.Boolean) @param('code', Types.String, true) @param('autoHide', Types.String, true) + @param('assign', Types.Array, true) async post( domainId: string, tid: ObjectID, beginAtDate: string, beginAtTime: string, duration: number, - title: string, content: string, rule: string, _pids: string, rated = false, _code = '', autoHide = false, + title: string, content: string, rule: string, _pids: string, rated = false, + _code = '', autoHide = false, assign: string[] = null, ) { if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i); @@ -249,7 +263,7 @@ export class ContestEditHandler extends Handler { const endAt = beginAtMoment.clone().add(duration, 'hours').toDate(); if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration'); const beginAt = beginAtMoment.toDate(); - await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, true); + await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, true); if (tid) { await contest.edit(domainId, tid, { title, content, rule, beginAt, endAt, pids, rated, @@ -267,13 +281,13 @@ export class ContestEditHandler extends Handler { await TaskModel.deleteMany(task); if (Date.now() <= endAt.getTime() && autoHide) { // eslint-disable-next-line no-await-in-loop - for (const pid of pids) await problem.edit(domainId, pid, { hidden: true }); + await Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true }))); await TaskModel.add({ ...task, executeAfter: endAt, }); } - await contest.edit(domainId, tid, { _code, autoHide }); + await contest.edit(domainId, tid, { assign, _code, autoHide }); this.response.body = { tid }; this.response.redirect = this.url('contest_detail', { tid }); } diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 761a6c234..10dd5b5e8 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -58,7 +58,11 @@ class DiscussionHandler extends Handler { // TODO(twd2): exclude problem/contest discussions? // TODO(iceboy): continuation based pagination. this.vnode = await discussion.getVnode(domainId, typeMapper[type], name, this.user._id); - if (this.vnode.hidden) this.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + if (this.vnode.assign?.length) { + if (!Set.intersection(this.vnode.assign, this.user.group).size) { + throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); + } + } if (this.ddoc) { this.ddoc.parentType = this.ddoc.parentType || this.vnode.type; this.ddoc.parentId = this.ddoc.parentId || this.vnode.id; @@ -78,9 +82,7 @@ class DiscussionMainHandler extends Handler { ); const udict = await user.getList(domainId, ddocs.map((ddoc) => ddoc.owner)); const [vndict, vnodes] = await Promise.all([ - discussion.getListVnodes( - domainId, ddocs, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN), - ), + discussion.getListVnodes(domainId, ddocs, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN), this.user.group), discussion.getNodes(domainId), ]); this.response.template = 'discussion_main_or_node.html'; diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index 4a23f0481..8e7ab1f06 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -3,7 +3,7 @@ import { Dictionary } from 'lodash'; import moment from 'moment-timezone'; import { DomainJoinAlreadyMemberError, DomainJoinForbiddenError, ForbiddenError, - InvalidJoinInvitationCodeError, RoleAlreadyExistError, ValidationError, + InvalidJoinInvitationCodeError, PermissionError, RoleAlreadyExistError, ValidationError, } from '../error'; import type { DomainDoc } from '../interface'; import avatar from '../lib/avatar'; @@ -19,6 +19,40 @@ import { Handler, param, post, query, Route, Types, } from '../service/server'; import { log2 } from '../utils'; +import { registerResolver, registerValue } from './api'; + +registerValue('GroupInfo', [ + ['name', 'String!'], + ['uids', '[Int]!'], +]); + +registerResolver('Query', 'domain(id: String)', 'Domain', async (args, ctx) => { + const ddoc = await domain.get(args.id); + if (!ddoc) return null; + const udoc = await user.getById(args.id, ctx.user._id); + if (!udoc.hasPerm(PERM.PERM_VIEW) && !udoc.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) return null; + ctx.udoc = udoc; + return ddoc; +}); + +registerResolver('Domain', 'manage', 'DomainManage', async (args, ctx) => { + if (!ctx.udoc.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN); + return {}; +}); + +registerResolver('DomainManage', 'group', 'DomainGroup', (args, ctx) => ctx.parent); +registerResolver( + 'DomainGroup', 'list(uid: Int)', '[GroupInfo]', + (args, ctx) => user.listGroup(ctx.parent._id, args.uid), +); +registerResolver( + 'DomainGroup', 'update(name: String!, uids: [Int]!)', 'Boolean', + async (args, ctx) => !!(await user.updateGroup(ctx.parent._id, args.name, args.uids)).upsertedCount, +); +registerResolver( + 'DomainGroup', 'del(name: String!)', 'Boolean', + async (args, ctx) => !!(await user.delGroup(ctx.parent._id, args.name)).deletedCount, +); class DomainRankHandler extends Handler { @query('page', Types.PositiveInt, true) @@ -184,7 +218,7 @@ class DomainRoleHandler extends ManageHandler { @param('roles', Types.Array) async postDelete(domainId: string, roles: string[]) { - if (Set.intersection(new Set(roles), new Set(['root', 'default', 'guest'])).size > 0) { + if (Set.intersection(roles, ['root', 'default', 'guest']).size > 0) { throw new ValidationError('role', null, 'You cannot delete root, default or guest roles'); } await domain.deleteRoles(domainId, roles); @@ -231,6 +265,25 @@ class DomainJoinApplicationsHandler extends ManageHandler { } } +class DomainUserGroupHandler extends ManageHandler { + async get() { + this.response.body = 'domain_group.html'; + } + + @param('name', Types.Name) + async postDel(domainId: string, name: string) { + await user.delGroup(domainId, name); + this.back(); + } + + @param('name', Types.Name) + @param('uids', Types.NumericArray) + async postUpdate(domainId: string, name: string, uids: number[]) { + await user.updateGroup(domainId, name, uids); + this.back(); + } +} + class DomainJoinHandler extends Handler { joinSettings: any; noCheckPermView = true; @@ -283,6 +336,7 @@ export async function apply() { Route('domain_user', '/domain/user', DomainUserHandler); Route('domain_permission', '/domain/permission', DomainPermissionHandler); Route('domain_role', '/domain/role', DomainRoleHandler); + Route('domain_group', '/domain/group', DomainUserGroupHandler); Route('domain_join_applications', '/domain/join_applications', DomainJoinApplicationsHandler); Route('domain_join', '/domain/join', DomainJoinHandler, PRIV.PRIV_USER_PROFILE); Route('domain_search', '/domain/search', DomainSearchHandler, PRIV.PRIV_USER_PROFILE); diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index 28acf1aa1..8a5e8a3aa 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -75,9 +75,7 @@ class HomeHandler extends Handler { async getDiscussion(domainId: string, limit = 20) { if (this.user.hasPerm(PERM.PERM_VIEW_DISCUSSION)) { const ddocs = await discussion.getMulti(domainId).limit(limit).toArray(); - const vndict = await discussion.getListVnodes( - domainId, ddocs, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN), - ); + 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 ['discussion', ddocs, vndict]; } @@ -103,7 +101,7 @@ class HomeHandler extends Handler { for (const psdoc of psdocs) psdict[psdoc.docId] = psdoc; const pdict = await ProblemModel.getList( domainId, psdocs.map((pdoc) => pdoc.docId), - this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, false, + this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, false, ); const pdocs = Object.keys(pdict).filter((i) => +i).map((i) => pdict[i]); return ['problem', pdocs, psdict]; diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index af7009513..7d92c7e3b 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -4,7 +4,8 @@ import moment from 'moment-timezone'; import { ObjectID } from 'mongodb'; import { Time } from '@hydrooj/utils/lib/utils'; import { - ContestNotFoundError, HomeworkNotLiveError, ValidationError, + ContestNotFoundError, ForbiddenError, HomeworkNotLiveError, + ValidationError, } from '../error'; import { PenaltyRules } from '../interface'; import paginate from '../lib/paginate'; @@ -40,6 +41,17 @@ class HomeworkMainHandler extends Handler { } class HomeworkDetailHandler extends Handler { + @param('tid', Types.ObjectID) + async prepare(domainId: string, tid: ObjectID) { + const tdoc = await contest.get(domainId, tid); + if (tdoc.rule !== 'homework') throw new ContestNotFoundError(domainId, tid); + if (tdoc.assign) { + if (!Set.intersection(tdoc.assign, this.user.group).size) { + throw new ForbiddenError('You are not assigned.'); + } + } + } + @param('tid', Types.ObjectID) @param('page', Types.PositiveInt, true) async get(domainId: string, tid: ObjectID, page = 1) { @@ -156,7 +168,7 @@ class HomeworkEditHandler extends Handler { const endAt = penaltySince.clone().add(extensionDays, 'days'); if (beginAt.isSameOrAfter(penaltySince)) throw new ValidationError('endAtDate', 'endAtTime'); if (penaltySince.isAfter(endAt)) throw new ValidationError('extensionDays'); - await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, true); + await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, true); if (!tid) { tid = await contest.add(domainId, title, content, this.user._id, 'homework', beginAt.toDate(), endAt.toDate(), pids, rated, diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index f28aeaa75..73f354b5d 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -1,17 +1,18 @@ import AdmZip from 'adm-zip'; import { statSync } from 'fs-extra'; -import { flatten, intersection, isSafeInteger } from 'lodash'; +import { intersection, isSafeInteger } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { sortFiles } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ForbiddenError, NoProblemError, NotFoundError, - PermissionError, ProblemNotFoundError, SolutionNotFoundError, ValidationError, + PermissionError, ProblemNotFoundError, SolutionNotFoundError, + ValidationError, } from '../error'; import { - ProblemConfig, - ProblemDoc, ProblemStatusDoc, Tdoc, User, + ProblemConfig, ProblemDoc, ProblemStatusDoc, + Tdoc, User, } from '../interface'; import paginate from '../lib/paginate'; import { isPid, parsePid as convertPid } from '../lib/validator'; @@ -32,7 +33,7 @@ import { } from '../service/server'; import { registerResolver, registerValue } from './api'; -export const parseCategory = (value: string) => flatten(value.replace(/,/g, ',').split(',')).map((e) => e.trim()); +export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value); registerValue('FileInfo', [ @@ -58,9 +59,7 @@ registerValue('Problem', [ ['difficulty', 'Int'], ['tag', '[String]'], ['hidden', 'Boolean'], -]); -registerValue('ProblemManage', [ - ['delete', 'Boolean!'], + ['assign', '[String]'], ]); registerResolver( @@ -68,7 +67,12 @@ registerResolver( async (arg, ctx) => { const pdoc = await problem.get(ctx.domainId, arg.pid || arg.id); if (!pdoc) return null; - if (pdoc.hidden && !ctx.user.own(pdoc)) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + if (pdoc.assign.length && !ctx.user.own(pdoc)) { + if (!Set.intersection(new Set(pdoc.assign), new Set(ctx.user.group)).size) { + throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); + } + } ctx.pdoc = pdoc; return pdoc; }, @@ -89,10 +93,33 @@ registerResolver( }, ); registerResolver( - 'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean)', 'Problem!', + 'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean, assign: [String])', 'Problem!', (arg, ctx) => problem.edit(ctx.domainId, ctx.pdoc.docId, arg), ); +function buildQuery(udoc: User) { + const q: FilterQuery = { + $or: [ + { + $and: [ + { + $or: [ + { assign: { $size: 0 } }, + { assign: { $in: udoc.group } }, + ], + }, + ], + }, + { owner: udoc._id }, + { maintainer: udoc._id }, + ], + }; + if (udoc.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) { + q.$or[0].$and.push({ hidden: false }); + } + return q; +} + export class ProblemHandler extends Handler { async cleanup() { if (this.response.template === 'problem_main.html' && this.request.json) { @@ -123,7 +150,7 @@ export class ProblemMainHandler extends ProblemHandler { async get(domainId: string, page = 1, q = '', category = []) { this.response.template = 'problem_main.html'; // eslint-disable-next-line @typescript-eslint/no-shadow - const query: FilterQuery = {}; + const query = buildQuery(this.user); const psdict = {}; const search = global.Hydro.lib.problemSearch; let sort: string[]; @@ -145,9 +172,6 @@ export class ProblemMainHandler extends ProblemHandler { sort = result; } else query.$text = { $search: q }; } - if (!this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) { - query.$or = [{ hidden: false }, { owner: this.user._id }, { maintainer: this.user._id }]; - } await bus.serial('problem/list', query, this); // eslint-disable-next-line prefer-const let [pdocs, ppcount, pcount] = fail @@ -160,7 +184,7 @@ export class ProblemMainHandler extends ProblemHandler { if (sort) pdocs = pdocs.sort((a, b) => sort.indexOf(`${a.domainId}/${a.docId}`) - sort.indexOf(`${b.domainId}/${b.docId}`)); if (q) { const pdoc = await problem.get(domainId, +q || q, problem.PROJECTION_LIST); - if (pdoc && (!pdoc.hidden || this.user.own(pdoc) || this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN))) { + if (pdoc && problem.canViewBy(pdoc, this.user)) { const count = pdocs.length; pdocs = pdocs.filter((doc) => doc.docId !== pdoc.docId); pdocs.unshift(pdoc); @@ -252,9 +276,8 @@ export class ProblemMainHandler extends ProblemHandler { export class ProblemRandomHandler extends ProblemHandler { @param('category', Types.Name, true, null, parseCategory) async get(domainId: string, category: string[] = []) { - const q: FilterQuery = category.length ? { $and: [] } : {}; - for (const tag of category) q.$and.push({ tag }); - if (!this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) q.hidden = false; + const q = buildQuery(this.user); + if (category.length) q.$and = category.map((tag) => ({ tag })); await bus.serial('problem/list', q, this); const pid = await problem.random(domainId, q); if (!pid) throw new NoProblemError(); @@ -284,8 +307,8 @@ export class ProblemDetailHandler extends ProblemHandler { if (!showAccept) this.pdoc.nAccept = 0; if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(tid); if (!contest.isDone(this.tdoc) && !this.tsdoc?.attend) throw new ContestNotAttendedError(tid); - } else if (this.pdoc.hidden && !this.user.own(this.pdoc)) { - this.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + } else if (!problem.canViewBy(this.pdoc, this.user)) { + throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); } let ddoc = this.domain; if (this.pdoc.reference) { @@ -462,13 +485,14 @@ export class ProblemEditHandler extends ProblemManageHandler { @post('hidden', Types.Boolean) @post('tag', Types.Content, true, null, parseCategory) @post('difficulty', Types.PositiveInt, (i) => +i <= 10, true) + @post('assign', Types.CommaSeperatedArray) async post( domainId: string, pid: string | number, title: string, content: string, - newPid: string = '', hidden = false, tag: string[] = [], difficulty = 0, + newPid: string = '', hidden = false, tag: string[] = [], difficulty = 0, assign: string[] = [], ) { if (newPid !== this.pdoc.pid && await problem.get(domainId, newPid)) throw new BadRequestError('new pid exists'); const $update: Partial = { - title, content, pid: newPid, hidden, tag: tag ?? [], difficulty, + title, content, pid: newPid, hidden, assign, tag: tag ?? [], difficulty, }; let pdoc = await problem.get(domainId, pid); pdoc = await problem.edit(domainId, pdoc.docId, $update); @@ -765,9 +789,13 @@ export class ProblemCreateHandler extends Handler { @post('hidden', Types.Boolean) @post('difficulty', Types.PositiveInt, (i) => +i <= 10, true) @post('tag', Types.Content, true, null, parseCategory) - async post(domainId: string, title: string, content: string, pid: string, hidden = false, difficulty = 0, tag: string[] = []) { + @post('assign', Types.CommaSeperatedArray) + async post( + domainId: string, title: string, content: string, pid: string, + hidden = false, difficulty = 0, tag: string[] = [], assign: string[] = [], + ) { if (pid && await problem.get(domainId, pid)) throw new BadRequestError('invalid pid'); - const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden); + const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden, assign); if (difficulty) await problem.edit(domainId, docId, { difficulty }); this.response.body = { pid: pid || docId }; this.response.redirect = this.url('problem_files', { pid: pid || docId }); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index be0b6c127..6616db1d3 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -69,13 +69,13 @@ class RecordListHandler extends Handler { const rdocs = invalid ? [] as RecordDoc[] : await cursor.skip((page - 1) * limit).limit(limit).toArray(); - const canViewProblem = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM); - const canViewProblemHidden = (!!tid) || this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + const canViewProblem = tid || this.user.hasPerm(PERM.PERM_VIEW_PROBLEM); + const canViewHiddenProblem = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id; const [udict, pdict] = full ? [{}, {}] : await Promise.all([ user.getList(domainId, rdocs.map((rdoc) => rdoc.uid)), canViewProblem - ? problem.getList(domainId, rdocs.map((rdoc) => rdoc.pid), canViewProblemHidden || this.user._id, false) + ? problem.getList(domainId, rdocs.map((rdoc) => rdoc.pid), canViewHiddenProblem, this.user.group, false) : Object.fromEntries([rdocs.map((rdoc) => [rdoc.pid, { ...problem.default, pid: rdoc.pid }])]), ]); this.response.body = { @@ -132,10 +132,9 @@ class RecordDetailHandler extends Handler { rdoc.compilerTexts = []; } - let canView = pdoc && this.user.own(pdoc); - canView ||= !pdoc?.hidden && this.user.hasPerm(PERM.PERM_VIEW_PROBLEM); - canView ||= !!rdoc.contest || this.user._id === rdoc.uid; - if (!canView) this.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + if (pdoc && !(rdoc.contest && this.user._id === rdoc.uid)) { + if (!problem.canViewBy(pdoc, this.user)) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); + } this.response.body = { udoc, rdoc, pdoc, tdoc, @@ -250,7 +249,7 @@ class RecordMainConnectionHandler extends ConnectionHandler { ]); const tdoc = this.tid ? this.tdoc || await contest.get(rdoc.domainId, new ObjectID(this.tid)) : null; if (pdoc && !rdoc.contest) { - if (pdoc.hidden && !this.user.own(pdoc) && !this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) pdoc = null; + if (problem.canViewBy(pdoc, this.user)) pdoc = null; if (!this.user.hasPerm(PERM.PERM_VIEW_PROBLEM)) pdoc = null; } if (this.pretest) this.send({ rdoc }); @@ -293,11 +292,9 @@ class RecordDetailConnectionHandler extends ConnectionHandler { rdoc.compilerTexts = []; } - let canView = pdoc && this.user.own(pdoc); - canView ||= !pdoc?.hidden && this.user.hasPerm(PERM.PERM_VIEW_PROBLEM); - canView ||= this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); - canView ||= !!rdoc.contest || this.user._id === rdoc.uid; - if (!canView) throw new PermissionError(PERM.PERM_READ_RECORD_CODE); + if (!(rdoc.contest && this.user._id === rdoc.uid)) { + if (!problem.canViewBy(pdoc, this.user)) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); + } this.rid = rid.toString(); this.cleanup = bus.on('record/change', this.onRecordChange.bind(this)); diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index b781a33ef..d5011f04e 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -97,9 +97,10 @@ class TrainingDetailHandler extends Handler { const tdoc = await training.get(domainId, tid); await bus.serial('training/get', tdoc, this); const pids = training.getPids(tdoc.dag); + const canViewHidden = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id; const [owner, pdict] = await Promise.all([ user.getById(domainId, tdoc.owner), - problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, true), + problem.getList(domainId, pids, canViewHidden, this.user.group, true), ]); const psdict = await problem.getListStatus(domainId, this.user._id, pids); const donePids = new Set(); @@ -119,7 +120,7 @@ class TrainingDetailHandler extends Handler { for (const node of tdoc.dag) { ndict[node._id] = node; const totalCount = node.pids.length; - const doneCount = Set.intersection(new Set(node.pids), donePids).size; + const doneCount = Set.intersection(node.pids, donePids).size; const nsdoc = { progress: totalCount ? Math.floor(100 * (doneCount / totalCount)) : 100, isDone: training.isDone(node, doneNids, donePids), diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index e8d5203a9..76a4f313b 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -303,7 +303,12 @@ class UserDetailHandler extends Handler { const canViewHidden = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id; await Promise.all([domainId, ...(union?.union || [])].map(async (did) => { const psdocs = await problem.getMultiStatus(did, { uid, status: STATUS.STATUS_ACCEPTED }).toArray(); - pdocs.push(...Object.values(await problem.getList(did, psdocs.map((i) => i.docId), canViewHidden, false, undefined, true))); + pdocs.push(...Object.values( + await problem.getList( + did, psdocs.map((i) => i.docId), canViewHidden, + this.user.group, false, undefined, true, + ), + )); })); for (const pdoc of pdocs) { for (const tag of pdoc.tag) { diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 4bba9c4cc..cc46bf6e4 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -97,32 +97,33 @@ export interface GDoc { export type ownerInfo = { owner: number, maintainer?: number[] }; export interface User extends Record { - _id: number, - _udoc: Udoc, - _dudoc: any, - _salt: string, - _hash: string, - _regip: string, - _loginip: string, - _tfa: string, + _id: number; + _udoc: Udoc; + _dudoc: any; + _salt: string; + _hash: string; + _regip: string; + _loginip: string; + _tfa: string; - mail: string, - uname: string, - hashType: string, - priv: number, - regat: Date, - loginat: Date, - perm: bigint, - scope: bigint, - role: string, - tfa: boolean, + mail: string; + uname: string; + hashType: string; + priv: number; + regat: Date; + loginat: Date; + perm: bigint; + scope: bigint; + role: string; + group?: string[]; + tfa: boolean; own(doc: T, checkPerm: bigint): boolean own(doc: T, exact: boolean): boolean own(doc: T): boolean own(doc: T): boolean; - hasPerm: (...perm: bigint[]) => boolean, - hasPriv: (...priv: number[]) => boolean, - checkPassword: (password: string) => void, + hasPerm: (...perm: bigint[]) => boolean; + hasPriv: (...priv: number[]) => boolean; + checkPassword: (password: string) => void; } export type Udict = NumericDictionary; @@ -244,7 +245,8 @@ declare module './model/problem' { tag: string[]; data: FileInfo[]; additional_file: FileInfo[]; - hidden: boolean; + hidden?: boolean; + assign: string[]; html?: boolean; stats?: any; difficulty?: number; diff --git a/packages/hydrooj/src/model/discussion.ts b/packages/hydrooj/src/model/discussion.ts index 67e77b61b..607c84a6d 100644 --- a/packages/hydrooj/src/model/discussion.ts +++ b/packages/hydrooj/src/model/discussion.ts @@ -275,17 +275,16 @@ export function getNodes(domainId: string) { return document.getMulti(domainId, document.TYPE_DISCUSSION_NODE).toArray(); } -export async function getListVnodes(domainId: string, ddocs: any, getHidden: boolean) { - const tasks = []; +export async function getListVnodes(domainId: string, ddocs: any, getHidden = false, assign: string[] = []) { const res = {}; async function task(ddoc: DiscussionDoc) { const vnode = await getVnode(domainId, ddoc.parentType, ddoc.parentId.toString()); if (!res[ddoc.parentType]) res[ddoc.parentType] = {}; - if (vnode.hidden && !getHidden) return; + if (!getHidden && vnode.hidden) return; + if (vnode.assign?.length && Set.intersection(vnode.assign, assign).size) return; res[ddoc.parentType][ddoc.parentId] = vnode; } - for (const ddoc of ddocs) tasks.push(task(ddoc)); - await Promise.all(tasks); + await Promise.all(ddocs.map((ddoc) => task(ddoc))); return res; } diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index d8ee27210..382e39cc7 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -6,7 +6,7 @@ import type { Readable } from 'stream'; import { streamToBuffer } from '@hydrooj/utils/lib/utils'; import { ProblemNotFoundError, ValidationError } from '../error'; import type { - Document, ProblemDict, ProblemStatusDoc, + Document, ProblemDict, ProblemStatusDoc, User, } from '../interface'; import { parseConfig } from '../lib/testdataConfig'; import * as bus from '../service/bus'; @@ -31,7 +31,7 @@ export class ProblemModel { static PROJECTION_LIST: Field[] = [ '_id', 'domainId', 'docType', 'docId', 'pid', 'owner', 'title', 'nSubmit', 'nAccept', 'difficulty', - 'tag', 'hidden', 'stats', + 'tag', 'assign', 'stats', ]; static PROJECTION_PUBLIC: Field[] = [ @@ -57,6 +57,7 @@ export class ProblemModel { additional_file: [], stats: {}, hidden: true, + assign: [], config: '', difficulty: 0, }; @@ -78,27 +79,31 @@ export class ProblemModel { additional_file: [], stats: {}, hidden: true, + assign: [], config: '', difficulty: 0, }; static async add( domainId: string, pid: string = '', title: string, content: string, owner: number, - tag: string[] = [], hidden = false, + tag: string[] = [], hidden = false, assign: string[] = [], ) { const [doc] = await ProblemModel.getMulti(domainId, {}) .sort({ docId: -1 }).limit(1).project({ docId: 1 }) .toArray(); - const result = await ProblemModel.addWithId(domainId, (doc?.docId || 0) + 1, pid, title, content, owner, tag, hidden); + const result = await ProblemModel.addWithId( + domainId, (doc?.docId || 0) + 1, pid, + title, content, owner, tag, hidden, assign, + ); return result; } static async addWithId( domainId: string, docId: number, pid: string = '', title: string, - content: string, owner: number, tag: string[] = [], hidden = false, + content: string, owner: number, tag: string[] = [], hidden = false, assign: string[] = [], ) { const args: Partial = { - title, tag, hidden, nSubmit: 0, nAccept: 0, sort: sortable(pid || `P${docId}`), + title, tag, hidden, assign, nSubmit: 0, nAccept: 0, sort: sortable(pid || `P${docId}`), }; if (pid) args.pid = pid; await bus.serial('problem/before-add', domainId, content, owner, docId, args); @@ -279,8 +284,8 @@ export class ProblemModel { } static async getList( - domainId: string, pids: number[], - getHidden: number | boolean = false, doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC, + domainId: string, pids: number[], canViewHidden: number | boolean = false, + group: string[] = [], doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC, indexByDocIdOnly = false, ): Promise { const r: Record = {}; @@ -288,7 +293,12 @@ export class ProblemModel { const q: any = { $or: [{ docId: { $in: pids } }, { pid: { $in: pids } }] }; let pdocs = await document.getMulti(domainId, document.TYPE_PROBLEM, q) .project(buildProjection(projection)).toArray(); - if (getHidden !== true) pdocs = pdocs.filter((i) => !i.hidden || i.owner === getHidden); + if (group.length > 0) { + pdocs = pdocs.filter((i) => !i.assign?.length || Set.intersection(group, i.assign).size); + } + if (canViewHidden !== true) { + pdocs = pdocs.filter((i) => i.owner === canViewHidden || !i.hidden); + } for (const pdoc of pdocs) { try { // eslint-disable-next-line no-await-in-loop @@ -352,6 +362,14 @@ export class ProblemModel { static setStar(domainId: string, pid: number, uid: number, star: boolean) { return document.setStatus(domainId, document.TYPE_PROBLEM, pid, uid, { star }); } + + static canViewBy(pdoc: ProblemDoc, udoc: User) { + if (!udoc.hasPerm(PERM.PERM_VIEW_PROBLEM)) return false; + if (udoc.own(pdoc)) return true; + if (pdoc.hidden && !udoc.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) return false; + if (!pdoc.assign.length) return true; + return !!Set.intersection(pdoc.assign, udoc.group).size; + } } bus.on('problem/addTestdata', async (domainId, docId, name) => { diff --git a/packages/hydrooj/src/model/task.ts b/packages/hydrooj/src/model/task.ts index 58c2856ec..de3b2e11b 100644 --- a/packages/hydrooj/src/model/task.ts +++ b/packages/hydrooj/src/model/task.ts @@ -158,7 +158,7 @@ bus.once('app/started', async () => { while (true) { // eslint-disable-next-line no-await-in-loop const res = await collEvent.findOneAndUpdate( - { ack: { $not: { $elemMatch: { $eq: id } } } }, + { ack: { $nin: [id] } }, { $push: { ack: id } }, ); // eslint-disable-next-line no-await-in-loop diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index 6071dc4eb..81179a4c3 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -67,6 +67,7 @@ class User implements _User { scope: bigint; _files: FileInfo[]; tfa: boolean; + group?: string[]; [key: string]: any; constructor(udoc: Udoc, dudoc, scope = PERM.PERM_ALL) { @@ -91,6 +92,7 @@ class User implements _User { this.scope = typeof scope === 'string' ? BigInt(scope) : scope; this.role = dudoc.role || 'default'; this.tfa = !!udoc.tfa; + if (dudoc.group) this.group = [...dudoc.group, this._id.toString()]; for (const key in setting.SETTINGS_BY_KEY) { this[key] = udoc[key] ?? (setting.SETTINGS_BY_KEY[key].value || system.get(`preference.${key}`)); @@ -175,6 +177,8 @@ class UserModel { const udoc = await (_id < -999 ? collV : coll).findOne({ _id }); if (!udoc) return null; const dudoc = await domain.getDomainUser(domainId, udoc); + const groups = await UserModel.listGroup(domainId, _id); + dudoc.group = groups.map((i) => i.name); if (typeof scope === 'string') scope = BigInt(scope); const res = await new User(udoc, dudoc, scope).init(); cache.set(`id/${res._id}/${domainId}`, res); @@ -373,10 +377,6 @@ class UserModel { ]); } - static createGroup(domainId: string, name: string, uids: number[]) { - return collGroup.insertOne({ domainId, name, uids }); - } - static async listGroup(domainId: string, uid?: number) { const groups = await collGroup.find(uid ? { domainId, uids: uid } : { domainId }).toArray(); if (uid) { diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 2fe51c313..f295cd742 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -84,6 +84,7 @@ export interface Types { Range: (range: Array | Dictionary) => Type, Array: Type, NumericArray: Type, + CommaSeperatedArray: Type, Set: Type, } @@ -163,6 +164,10 @@ export const Types: Types = { if (v instanceof Array) return !v.map(Number).includes(NaN); return !Number.isNaN(+v); }], + CommaSeperatedArray: [ + (v) => v.toString().replace(/,/g, ',').split(',').map((e) => e.trim()).filter((i) => i), + (v) => v.toString(), + ], Set: [(v) => { if (v instanceof Array) return new Set(v); return v ? new Set([v]) : new Set(); diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index fc728347d..15ce46852 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -664,6 +664,14 @@ const scripts: UpgradeScript[] = [ await db.collection('oplog').deleteMany({ type: 'user.login' }); return true; }, + async function _57_58() { + const _FRESH_INSTALL_IGNORE = 1; + await db.collection('document').updateMany( + { docType: document.TYPE_PROBLEM, assign: null }, + { $set: { assign: [] } }, + ); + return true; + }, ]; export default scripts; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 3bfbd8876..5f87d1f2f 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.27.17", + "version": "4.27.18", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index 5933486e1..1186fdee5 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -27,7 +27,7 @@ declare global { } interface SetConstructor { isSuperset: (set: Set, subset: Set) => boolean; - intersection: (setA: Set, setB: Set) => Set; + intersection: (setA: Set | Array, setB: Set | Array) => Set; union: (setA: Set, setB: Set) => Set; } } @@ -130,9 +130,11 @@ Set.union = function Union(setA: Set, setB: Set) { return union; }; -Set.intersection = function Intersection(setA: Set, setB: Set) { +Set.intersection = function Intersection(A: Set | Array = [], B: Set | Array = []) { const intersection = new Set(); - for (const elem of setB) if (setA.has(elem)) intersection.add(elem); + if (A instanceof Array) A = new Set(A); + if (B instanceof Array) B = new Set(B); + for (const elem of B) if (A.has(elem)) intersection.add(elem); return intersection; }; diff --git a/packages/utils/package.json b/packages/utils/package.json index 0501535af..7d1bbe4eb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.1.10", + "version": "1.1.11", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git",