diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index c3c9005f1..44dc54748 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -1,7 +1,7 @@ import { isSafeInteger } from 'lodash'; import { ObjectId } from 'mongodb'; import { - DiscussionLockedError, DiscussionNotFoundError, DocumentNotFoundError, + DiscussionLockedError, DiscussionNodeNotFoundError, DiscussionNotFoundError, DocumentNotFoundError, PermissionError, } from '../error'; import { DiscussionDoc, DiscussionReplyDoc, DiscussionTailReplyDoc } from '../interface'; @@ -30,7 +30,7 @@ class DiscussionHandler extends Handler { vnode?: any; @param('type', Types.Range(Object.keys(typeMapper)), true) - @param('name', Types.Name, true) + @param('name', Types.String, true) @param('did', Types.ObjectId, true) @param('drid', Types.ObjectId, true) @param('drrid', Types.ObjectId, true) @@ -55,15 +55,10 @@ class DiscussionHandler extends Handler { } } } - // TODO(twd2): do more visibility check eg. contest // 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.assign?.length && !this.user.own(this.vnode)) { - if (!Set.intersection(this.vnode.assign, this.user.group).size) { - throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); - } - } + if (!discussion.checkVNodeVisibility(typeMapper[type], this.vnode, this.user)) throw new DiscussionNodeNotFoundError(this.vnode.id); if (this.ddoc) { this.ddoc.parentType ||= this.vnode.type; this.ddoc.parentId ||= this.vnode.id; @@ -73,11 +68,13 @@ class DiscussionHandler extends Handler { class DiscussionMainHandler extends Handler { @param('page', Types.PositiveInt, true) - async get(domainId: string, page = 1) { + @param('all', Types.Boolean) + async get(domainId: string, page = 1, all = false) { // Limit to known types const parentType = { $in: Object.keys(typeMapper).map((i) => typeMapper[i]) }; + all &&= this.user.hasPerm(PERM.PERM_MOD_BADGE); const [ddocs, dpcount] = await paginate( - discussion.getMulti(domainId, { parentType }), + discussion.getMulti(domainId, { parentType, ...all ? {} : { hidden: false } }), page, system.get('pagination.discussion'), ); @@ -95,15 +92,16 @@ class DiscussionMainHandler extends Handler { class DiscussionNodeHandler extends DiscussionHandler { @param('type', Types.Range(Object.keys(typeMapper))) - @param('name', Types.Name) + @param('name', Types.String) @param('page', Types.PositiveInt, true) async get(domainId: string, type: string, _name: string, page = 1) { let name: ObjectId | string | number; if (ObjectId.isValid(_name)) name = new ObjectId(_name); else if (isSafeInteger(parseInt(_name, 10))) name = parseInt(_name, 10); else name = _name; + const hidden = this.user.own(this.vnode) || this.user.hasPerm(PERM.PERM_EDIT_DISCUSSION) ? {} : { hidden: false }; const [ddocs, dpcount] = await paginate( - discussion.getMulti(domainId, { parentType: typeMapper[type], parentId: name }), + discussion.getMulti(domainId, { parentType: typeMapper[type], parentId: name, ...hidden }), page, system.get('pagination.discussion'), ); @@ -152,9 +150,10 @@ class DiscussionCreateHandler extends DiscussionHandler { await this.limitRate('add_discussion', 3600, 60); if (highlight) this.checkPerm(PERM.PERM_HIGHLIGHT_DISCUSSION); if (pin) this.checkPerm(PERM.PERM_PIN_DISCUSSION); + const hidden = this.vnode.hidden ?? false; const did = await discussion.add( domainId, typeMapper[type], this.vnode.id, this.user._id, - title, content, this.request.ip, highlight, pin, + title, content, this.request.ip, highlight, pin, hidden, ); this.response.body = { did }; this.response.redirect = this.url('discussion_detail', { did }); @@ -397,9 +396,10 @@ class DiscussionEditHandler extends DiscussionHandler { else this.checkPerm(PERM.PERM_EDIT_DISCUSSION_SELF); if (!this.user.hasPerm(PERM.PERM_HIGHLIGHT_DISCUSSION)) highlight = this.ddoc.highlight; if (!this.user.hasPerm(PERM.PERM_PIN_DISCUSSION)) pin = this.ddoc.pin; + const hidden = this.vnode.hidden ?? false; await Promise.all([ discussion.edit(domainId, did, { - title, highlight, pin, content, editor: this.user._id, edited: true, + title, highlight, pin, content, editor: this.user._id, edited: true, hidden, }), oplog.log(this, 'discussion.edit', this.ddoc), ]); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 6995455ac..78840446e 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -462,6 +462,7 @@ declare module './model/discussion' { sort: number; lastRCount: number; lock?: boolean; + hidden?: boolean; } } diff --git a/packages/hydrooj/src/model/discussion.ts b/packages/hydrooj/src/model/discussion.ts index a436ca1d4..7f1ac1ec5 100644 --- a/packages/hydrooj/src/model/discussion.ts +++ b/packages/hydrooj/src/model/discussion.ts @@ -9,10 +9,12 @@ import * as bus from '../service/bus'; import db from '../service/db'; import { NumberKeys } from '../typeutils'; import { buildProjection } from '../utils'; +import { PERM } from './builtin'; import * as contest from './contest'; import * as document from './document'; import problem from './problem'; import * as training from './training'; +import { User } from './user'; export interface DiscussionDoc extends Document { } export type Field = keyof DiscussionDoc; @@ -20,7 +22,7 @@ export type Field = keyof DiscussionDoc; export const PROJECTION_LIST: Field[] = [ '_id', 'domainId', 'docType', 'docId', 'highlight', 'nReply', 'views', 'pin', 'updateAt', 'owner', - 'parentId', 'parentType', 'title', + 'parentId', 'parentType', 'title', 'hidden', ]; export const PROJECTION_PUBLIC: Field[] = [ ...PROJECTION_LIST, 'content', 'edited', 'react', 'maintainer', @@ -43,7 +45,7 @@ export const coll = db.collection('discussion.history'); export async function add( domainId: string, parentType: number, parentId: ObjectId | number | string, owner: number, title: string, content: string, - ip: string | null = null, highlight: boolean, pin: boolean, + ip: string | null = null, highlight: boolean, pin: boolean, hidden = false, ): Promise { const time = new Date(); const payload: Partial = { @@ -61,6 +63,7 @@ export async function add( updateAt: time, views: 0, sort: 100, + hidden, }; await bus.parallel('discussion/before-add', payload); const res = await document.add( @@ -271,9 +274,8 @@ export function flushNodes(domainId: string) { export async function getVnode(domainId: string, type: number, id: string, uid?: number) { if (type === document.TYPE_PROBLEM) { - let pdoc = await problem.get(domainId, Number.isSafeInteger(+id) ? +id : id, problem.PROJECTION_LIST); + const pdoc = await problem.get(domainId, Number.isSafeInteger(+id) ? +id : id, problem.PROJECTION_LIST); if (!pdoc) throw new DiscussionNodeNotFoundError(id); - if (pdoc.hidden) pdoc = problem.default; return { ...pdoc, type, id: pdoc.docId }; } if ([document.TYPE_CONTEST, document.TYPE_TRAINING, document.TYPE_HOMEWORK].includes(type as any)) { @@ -285,6 +287,7 @@ export async function getVnode(domainId: string, type: number, id: string, uid?: const tsdoc = await model.getStatus(domainId, _id, uid); tdoc.attend = tsdoc?.attend || tsdoc?.enroll; } + if (type === document.TYPE_CONTEST) tdoc.hidden = !(contest.isDone(tdoc)); return { ...tdoc, type, id: _id }; } return { @@ -292,6 +295,7 @@ export async function getVnode(domainId: string, type: number, id: string, uid?: ...await getNode(domainId, id), type, id, + owner: 1, }; } @@ -312,6 +316,16 @@ export async function getListVnodes(domainId: string, ddocs: any, getHidden = fa return res; } +export function checkVNodeVisibility(type: number, vnode: any, user: User) { + if (type === document.TYPE_PROBLEM) { + if (vnode.hidden && !(user.own(vnode) || user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN))) return false; + } + if ([document.TYPE_CONTEST, document.TYPE_TRAINING, document.TYPE_HOMEWORK].includes(type as any)) { + if (!user.own(vnode) && vnode.assign?.length && !Set.intersection(vnode.assign, user.group).size) return false; + } + return true; +} + export function apply(ctx: Context) { ctx.on('problem/delete', async (domainId, docId) => { const dids = await document.getMulti( @@ -328,6 +342,13 @@ export function apply(ctx: Context) { document.deleteMulti(domainId, document.TYPE_DISCUSSION_REPLY, { docId: { $in: drids } }), ]); }); + ctx.on('problem/edit', async (result) => { + const dids = await document.getMulti( + result.domainId, document.TYPE_DISCUSSION, + { parentType: document.TYPE_PROBLEM, parentId: result.docId }, + ).project({ docId: 1 }).map((ddoc) => ddoc.docId).toArray(); + return await coll.updateMany({ _id: { $in: dids } }, { $set: { hidden: result.hidden } }); + }); } global.Hydro.model.discussion = { @@ -367,4 +388,5 @@ global.Hydro.model.discussion = { getNodes, getVnode, getListVnodes, + checkVNodeVisibility, }; diff --git a/packages/ui-default/templates/partials/discussion_list.html b/packages/ui-default/templates/partials/discussion_list.html index eb8acb14c..4255f3c89 100644 --- a/packages/ui-default/templates/partials/discussion_list.html +++ b/packages/ui-default/templates/partials/discussion_list.html @@ -35,6 +35,9 @@ @ {{ datetimeSpan(ddoc.updateAt)|safe }} + {%- if ddoc.hidden -%} +
  • ({{ _('Hidden') }})
  • + {%- endif -%}