diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 2b49e7abb..344d5d8ec 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -10,8 +10,8 @@ import { } from '@hydrooj/utils/lib/utils'; import { Context, Service } from '../context'; import { - BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, - ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, + BadRequestError, ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, + ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, ValidationError, } from '../error'; import { ContestStatusDoc, FileInfo, ScoreboardConfig, Tdoc } from '../interface'; @@ -25,7 +25,7 @@ import problem from '../model/problem'; import record from '../model/record'; import ScheduleModel from '../model/schedule'; import storage from '../model/storage'; -import user from '../model/user'; +import user, { collV, deleteUserCache } from '../model/user'; import { Handler, param, post, Type, Types, } from '../service/server'; @@ -66,6 +66,7 @@ export class ContestListHandler extends Handler { const [tdocs, tpcount] = await this.paginate(cursor, page, 'contest'); const tids = []; for (const tdoc of tdocs) tids.push(tdoc.docId); + // FIXME: Team status need to be queried here. const tsdict = await contest.getListStatus(domainId, this.user._id, tids); const groupsFilter = groups.filter((i) => !Number.isSafeInteger(+i)); this.response.template = 'contest_main.html'; @@ -78,14 +79,16 @@ export class ContestListHandler extends Handler { export class ContestDetailBaseHandler extends Handler { tdoc?: Tdoc; tsdoc?: ContestStatusDoc; + team?: number; @param('tid', Types.ObjectId, true) async __prepare(domainId: string, tid: ObjectId) { if (!tid) return; // ProblemDetailHandler also extends from ContestDetailBaseHandler - [this.tdoc, this.tsdoc] = await Promise.all([ - contest.get(domainId, tid), - contest.getStatus(domainId, tid, this.user._id), - ]); + this.tdoc = await contest.get(domainId, tid); + if (this.tdoc.allowTeam) { + this.team = await contest.getTeamVuser(domainId, tid, this.user._id) || undefined; + } + this.tsdoc = await contest.getStatus(domainId, tid, this.team ?? this.user._id); if (this.tdoc.assign?.length && !this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST)) { const groups = await user.listGroup(domainId, this.user._id); if (!new Set(this.tdoc.assign).intersection(new Set(groups.map((i) => i.name))).size) { @@ -103,7 +106,11 @@ export class ContestDetailBaseHandler extends Handler { tsdocAsPublic() { if (!this.tsdoc) return null; - return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : [])]); + return pick(this.tsdoc, [ + 'attend', 'subscribe', 'startAt', + 'teamName', 'members', // for team + ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : []), + ]); } @param('tid', Types.ObjectId, true) @@ -174,11 +181,26 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectId) @param('code', Types.String, true) - async postAttend(domainId: string, tid: ObjectId, code = '') { + @param('vuid', Types.Int, true) + async postAttend(domainId: string, tid: ObjectId, code = '', vuid?: number) { this.checkPerm(PERM.PERM_ATTEND_CONTEST); if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid); if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError('Contest Invitation', code); - await contest.attend(domainId, tid, this.user._id, { subscribe: 1 }); + if (vuid) { + if (!this.tdoc.allowTeam) throw new ValidationError('allowTeam'); + const v = await collV.findOne({ _id: vuid }); + if (!v?.members?.includes(this.user._id)) throw new PermissionError(PERM.PERM_ATTEND_CONTEST); + const conflicts = await Promise.all(v.members.map(async (uid) => ({ uid, vuser: await contest.getTeamVuser(domainId, tid, uid) }))); + const conflict = conflicts.find((c) => c.vuser); + if (conflict) throw new ContestAlreadyAttendedError(tid, conflict.uid); + await contest.attend(domainId, tid, vuid, { + subscribe: 1, + teamName: v.teamName, + members: v.members, + }); + } else { + await contest.attend(domainId, tid, this.user._id, { subscribe: 1 }); + } this.back(); } @@ -186,7 +208,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('subscribe', Types.Boolean) async postSubscribe(domainId: string, tid: ObjectId, subscribe = false) { if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); - await contest.setStatus(domainId, tid, this.user._id, { subscribe: subscribe ? 1 : 0 }); + await contest.setStatus(domainId, tid, this.team ?? this.user._id, { subscribe: subscribe ? 1 : 0 }); this.back(); } @@ -196,7 +218,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid); const now = new Date(); - await contest.setStatus(domainId, tid, this.user._id, { endAt: now, ...(!this.tsdoc.startAt ? { startAt: now } : {}) }); + await contest.setStatus(domainId, tid, this.team ?? this.user._id, { endAt: now, ...(!this.tsdoc.startAt ? { startAt: now } : {}) }); this.back(); } } @@ -307,7 +329,7 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { this.response.body.showScore = Object.values(this.tdoc.score || {}).some((i) => i && i !== 100); if (!this.tsdoc) return; if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) { - await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); + await contest.setStatus(domainId, tid, this.team ?? this.user._id, { startAt: new Date() }); this.tsdoc.startAt = new Date(); } this.response.body.tsdoc = this.tsdocAsPublic(); @@ -417,13 +439,14 @@ export class ContestEditHandler extends Handler { @param('allowViewCode', Types.Boolean) @param('allowPrint', Types.Boolean) @param('keepScoreboardHidden', Types.Boolean) + @param('allowTeam', Types.Boolean) @param('langs', Types.CommaSeperatedArray, true) async postUpdate( domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string, duration: number, title: string, content: string, rule: string, _pids: string, rated = false, _code = '', autoHide = false, assign: string[] = [], lock: number = null, contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, allowPrint = false, - keepScoreboardHidden = false, langs: string[] = [], + keepScoreboardHidden = false, allowTeam = false, langs: string[] = [], ) { if (!Object.keys(contest.RULES).includes(rule) || contest.RULES[rule].hidden) throw new ValidationError('rule'); if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM); @@ -464,8 +487,9 @@ export class ContestEditHandler extends Handler { executeAfter: endAt, }); } + // FIXME: allowTeam cannot be disabled once enabled. await contest.edit(domainId, tid, { - assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, keepScoreboardHidden, langs, + assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, keepScoreboardHidden, allowTeam, langs, }); this.response.body = { tid }; this.response.redirect = this.url('contest_detail', { tid }); @@ -692,7 +716,7 @@ export class ContestFileDownloadHandler extends ContestDetailBaseHandler { if (type === 'private' && !this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid); - if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); + if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.team ?? this.user._id, { startAt: new Date() }); } this.response.addHeader('Cache-Control', 'public'); const target = `contest/${domainId}/${tid}/${type}/${filename}`; @@ -917,9 +941,92 @@ declare module 'cordis' { } } +class ContestTeamHandler extends Handler { + async prepare() { + this.checkPriv(PRIV.PRIV_USER_PROFILE); + } + + async get({ domainId }) { + const [mine, invites] = await Promise.all([ + collV.find({ members: this.user._id }).toArray(), + collV.find({ invite: this.user._id }).toArray(), + ]); + const udict = await user.getList(domainId, mine.flatMap((t) => [...t.members, ...(t.invite || [])])); + this.response.template = 'contest_team.html'; + this.response.body = { mine, invites, udict, page_name: 'contest_team' }; + } + + private async mustMember(vuid: number) { + const v = await collV.findOne({ _id: vuid }); + if (!v?.members?.includes(this.user._id)) throw new PermissionError(PERM.PERM_ATTEND_CONTEST); + return v; + } + + private async mut(vuid: number, update: any) { + const v = await collV.findOneAndUpdate({ _id: vuid }, update, { returnDocument: 'after' }); + deleteUserCache(v); + return v; + } + + @param('name', Types.String, true) + async postCreate(domainId: string, name?: string) { + await user.createVuser(`team:${this.user._id}:${randomstring(6)}`, { + teamName: name?.trim() || this.user.uname, + members: [this.user._id], + }); + this.back(); + } + + @param('vuid', Types.Int) + @param('name', Types.String) + async postRename(domainId: string, vuid: number, name: string) { + await this.mustMember(vuid); + await this.mut(vuid, { $set: { teamName: name.trim() } }); + this.back(); + } + + @param('vuid', Types.Int) + @param('uid', Types.Int) + async postInvite(domainId: string, vuid: number, uid: number) { + const v = await this.mustMember(vuid); + if (v.members.includes(uid) || v.invite?.includes(uid)) throw new ValidationError('uid'); + await this.mut(vuid, { $addToSet: { invite: uid } }); + await message.send(this.user._id, uid, + `${this.user.uname} invites you to join team "${v.teamName}": ${this.url('contest_team')}`, + message.FLAG_RICHTEXT); + this.back(); + } + + @param('vuid', Types.Int) + async postAccept(domainId: string, vuid: number) { + const v = await collV.findOne({ _id: vuid }); + if (!v?.invite?.includes(this.user._id)) throw new ValidationError('vuid'); + await this.mut(vuid, { $pull: { invite: this.user._id }, $addToSet: { members: this.user._id } }); + this.back(); + } + + @param('vuid', Types.Int) + async postReject(domainId: string, vuid: number) { + const v = await collV.findOne({ _id: vuid }); + if (!v?.invite?.includes(this.user._id)) throw new ValidationError('vuid'); + await this.mut(vuid, { $pull: { invite: this.user._id } }); + this.back(); + } + + @param('vuid', Types.Int) + @param('uid', Types.Int, true) + async postLeave(domainId: string, vuid: number, uid?: number) { + // FIXME: remove uid in invite[] + await this.mustMember(vuid); + await this.mut(vuid, { $pull: { members: uid ?? this.user._id } }); + this.back(); + } +} + export async function apply(ctx: Context) { ctx.Route('contest_create', '/contest/create', ContestEditHandler); ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_team', '/contest/team', ContestTeamHandler); // before /contest/:tid: "team" is not a valid ObjectId ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index a2bd37421..44ff24b17 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -533,7 +533,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { await Promise.all([ problem.inc(domainId, this.pdoc.docId, 'nSubmit', 1), domain.incUserInDomain(domainId, this.user._id, 'nSubmit'), - tid && contest.updateStatus(domainId, tid, this.user._id, rid, this.pdoc.docId), + tid && contest.updateStatus(domainId, tid, this.team ?? this.user._id, rid, this.pdoc.docId), ]); } if (tid && !pretest && !contest.canShowSelfRecord.call(this, this.tdoc)) { @@ -560,7 +560,10 @@ export class ProblemHackHandler extends ProblemDetailHandler { if (this.tdoc.rule !== 'codeforces') throw new HackFailedError('This contest is not hackable.'); if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId); } - if (this.rdoc.uid === this.user._id) throw new HackFailedError('You cannot hack your own submission'); + if (this.rdoc.uid === this.user._id + || (tid && await contest.isSameTeam(domainId, tid, this.rdoc.uid, this.user._id))) { + throw new HackFailedError('You cannot hack your own submission'); + } if (this.psdoc?.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You must accept this problem before hacking.'); if (this.rdoc.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You cannot hack a unsuccessful submission.'); } diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 115bad631..577634296 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -24,6 +24,11 @@ import { buildProjection, Time } from '../utils'; import { ContestDetailBaseHandler } from './contest'; import { postJudge } from './judge'; +// FIXME: move this +const isOwnOrTeammateRecord = async (domainId: string, rdoc: RecordDoc, uid: number) => ( + rdoc.uid === uid || (!!rdoc.contest && await contest.isSameTeam(domainId, rdoc.contest, rdoc.uid, uid)) +); + export class RecordListHandler extends ContestDetailBaseHandler { @param('page', Types.PositiveInt, true) @param('pid', Types.ProblemId, true) @@ -53,16 +58,23 @@ export class RecordListHandler extends ContestDetailBaseHandler { if (udoc) q.uid = udoc._id; else invalid = true; } - if (q.uid !== this.user._id) this.checkPerm(PERM.PERM_VIEW_RECORD); + // Team: a member viewing the contest record list sees the whole team's submissions. + let teamMembers: number[] | null = null; + if (tid && this.team && (q.uid === undefined || q.uid === this.user._id)) { + // this.tsdoc is already the team vuser's tsdoc (swapped in __prepare); no extra DB fetch needed + teamMembers = this.tsdoc?.members?.length ? this.tsdoc.members : null; + } + if (q.uid !== this.user._id && !teamMembers) this.checkPerm(PERM.PERM_VIEW_RECORD); if (tid) { tdoc = await contest.get(domainId, tid); this.tdoc = tdoc; if (!tdoc) throw new ContestNotFoundError(domainId, pid); if (!contest.canShowScoreboard.call(this, tdoc, true)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - if (!contest[q.uid === this.user._id ? 'canShowSelfRecord' : 'canShowRecord'].call(this, tdoc, true)) { + const viewingSelf = q.uid === this.user._id || !!teamMembers; + if (!contest[viewingSelf ? 'canShowSelfRecord' : 'canShowRecord'].call(this, tdoc, true)) { throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } - if (!(await contest.getStatus(domainId, tid, this.user._id))?.attend) { + if (!this.team && !(await contest.getStatus(domainId, tid, this.user._id))?.attend) { const name = tdoc.rule === 'homework' ? "You haven't claimed this homework yet." : "You haven't attended this contest yet."; @@ -89,6 +101,7 @@ export class RecordListHandler extends ContestDetailBaseHandler { delete q.contest; q._id = { $gt: Time.getObjectID(new Date(Date.now() - 10 * Time.week)) }; } + if (teamMembers && !all && !allDomain) q.uid = { $in: teamMembers }; let cursor = record.getMulti(allDomain ? '' : domainId, q).sort('_id', -1); if (!full) cursor = cursor.project(buildProjection(record.PROJECTION_LIST)); const limit = full ? 10 : system.get('pagination.record'); @@ -136,7 +149,7 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { async prepare(domainId: string, rid: ObjectId) { this.rdoc = await record.get(domainId, rid); if (!this.rdoc) throw new RecordNotFoundError(rid); - if (this.rdoc.uid !== this.user._id) this.checkPerm(PERM.PERM_VIEW_RECORD); + if (!(await isOwnOrTeammateRecord(domainId, this.rdoc, this.user._id))) this.checkPerm(PERM.PERM_VIEW_RECORD); } async download() { @@ -171,8 +184,8 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { this.tdoc = await contest.get(domainId, rdoc.contest); let canView = this.user.own(this.tdoc); canView ||= contest.canShowRecord.call(this, this.tdoc); - canView ||= contest.canShowSelfRecord.call(this, this.tdoc, true) && rdoc.uid === this.user._id; - if (!canView && rdoc.uid !== this.user._id) throw new PermissionError(rid); + canView ||= contest.canShowSelfRecord.call(this, this.tdoc, true) && await isOwnOrTeammateRecord(domainId, rdoc, this.user._id); + if (!canView && !(await isOwnOrTeammateRecord(domainId, rdoc, this.user._id))) throw new PermissionError(rid); canViewDetail = canView; this.args.tid = this.tdoc.docId; if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { @@ -187,12 +200,13 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { user.getById(domainId, rdoc.uid), ]); - let canViewCode = rdoc.uid === this.user._id; + let canViewCode = await isOwnOrTeammateRecord(domainId, rdoc, this.user._id); canViewCode ||= this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE); canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE); canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE_ACCEPT) && self?.status === STATUS.STATUS_ACCEPTED; if (this.tdoc) { - this.tsdoc = await contest.getStatus(domainId, this.tdoc.docId, this.user._id); + const teamVuser = this.tdoc.allowTeam ? await contest.getTeamVuser(domainId, this.tdoc.docId, this.user._id) : null; + this.tsdoc = await contest.getStatus(domainId, this.tdoc.docId, teamVuser ?? this.user._id); canViewCode ||= this.user.own(this.tdoc); if (this.tdoc.allowViewCode && contest.isDone(this.tdoc)) { canViewCode ||= !!this.tsdoc?.attend; @@ -299,7 +313,12 @@ export class RecordMainConnectionHandler extends ConnectionHandler { else throw new UserNotFoundError(uidOrName); } } - if (this.uid !== this.user._id) this.checkPerm(PERM.PERM_VIEW_RECORD); + if (this.uid !== this.user._id) { + const tdoc = this.tdoc; + const sameTeam = !!tdoc && tdoc.allowTeam && typeof this.uid === 'number' + && await contest.isSameTeam(domainId, tdoc.docId, this.uid, this.user._id); + if (!sameTeam) this.checkPerm(PERM.PERM_VIEW_RECORD); + } if (pid) { const pdoc = await problem.get(domainId, pid); if (pdoc) this.pid = pdoc.docId; @@ -336,8 +355,9 @@ export class RecordMainConnectionHandler extends ConnectionHandler { if (!rdoc.contest && this.tid) return; if (rdoc.contest && ![this.tid, '000000000000000000000000'].includes(rdoc.contest.toString())) return; if (this.tid && rdoc.contest?.toString() !== '0'.repeat(24)) { - if (rdoc.uid !== this.user._id && !contest.canShowRecord.call(this, this.tdoc, true)) return; - if (rdoc.uid === this.user._id && !contest.canShowSelfRecord.call(this, this.tdoc, true)) return; + const own = await isOwnOrTeammateRecord(this.args.domainId, rdoc, this.user._id); + if (!own && !contest.canShowRecord.call(this, this.tdoc, true)) return; + if (own && !contest.canShowSelfRecord.call(this, this.tdoc, true)) return; } } } @@ -397,7 +417,7 @@ export class RecordDetailConnectionHandler extends ConnectionHandler { this.tdoc = await contest.get(domainId, rdoc.contest); let canView = this.user.own(this.tdoc); canView ||= contest.canShowRecord.call(this, this.tdoc); - canView ||= this.user._id === rdoc.uid && contest.canShowSelfRecord.call(this, this.tdoc); + canView ||= (await isOwnOrTeammateRecord(domainId, rdoc, this.user._id)) && contest.canShowSelfRecord.call(this, this.tdoc); if (!canView) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { this.applyProjection = true; @@ -408,12 +428,12 @@ export class RecordDetailConnectionHandler extends ConnectionHandler { problem.getStatus(domainId, rdoc.pid, this.user._id), ]); - this.canViewCode = rdoc.uid === this.user._id; + this.canViewCode = await isOwnOrTeammateRecord(domainId, rdoc, this.user._id); this.canViewCode ||= this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE); this.canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE); this.canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE_ACCEPT) && self?.status === STATUS.STATUS_ACCEPTED; - if (!rdoc.contest || this.user._id !== rdoc.uid) { + if (!rdoc.contest || !(await isOwnOrTeammateRecord(domainId, rdoc, this.user._id))) { if (!problem.canViewBy(pdoc, this.user)) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index a8034123a..481ac86c1 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -92,7 +92,7 @@ export interface Udoc extends Record { loginip: string; } -export interface VUdoc { +export interface VUdoc extends Record { _id: number; mail: string; mailLower: string; @@ -106,6 +106,11 @@ export interface VUdoc { loginat: Date; ip: ['127.0.0.1']; loginip: '127.0.0.1'; + + // for contest team + teamName?: string; + members?: number[]; + invite?: number[]; } export interface GDoc { @@ -277,6 +282,7 @@ export interface Tdoc extends Document { balloon?: Record; score?: Record; langs?: string[]; + allowTeam?: boolean; /** * In hours @@ -464,6 +470,8 @@ export interface ContestStatusDoc extends StatusDocBase, ContestStat { startAt?: Date; endAt?: Date; // 灵活时间模式的结束时间,或者是提前结束比赛的时间 rev?: number; + teamName?: string; + members?: number[]; } export interface TrainingStatusDoc extends StatusDocBase, Record { diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 0c5622858..4de2cce68 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -997,6 +997,17 @@ export async function unlockScoreboard(domainId: string, tid: ObjectId) { await recalcStatus(domainId, tid); } +export async function getTeamVuser(domainId: string, tid: ObjectId, uid: number): Promise { + const s = await getMultiStatus(domainId, { docId: tid, members: uid }).project({ uid: 1 }).limit(1).next(); + return s?.uid ?? null; +} + +export async function isSameTeam(domainId: string, tid: ObjectId, a: number, b: number): Promise { + if (a === b) return true; + const [va, vb] = await Promise.all([getTeamVuser(domainId, tid, a), getTeamVuser(domainId, tid, b)]); + return !!va && va === vb; +} + export function canViewHiddenScoreboard(this: { user: User }, tdoc: Tdoc) { if (this.user.own(tdoc)) return true; if (tdoc.rule === 'homework') return this.user.hasPerm(PERM.PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD); @@ -1147,6 +1158,8 @@ global.Hydro.model.contest = { add, getListStatus, getMultiStatus, + getTeamVuser, + isSameTeam, attend, edit, del, diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index 727abb65e..a5e171ee6 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -379,28 +379,44 @@ class UserModel { @ArgMethod static async ensureVuser(uname: string) { - const [[min], current] = await Promise.all([ - collV.find({}).sort({ _id: 1 }).limit(1).toArray(), - collV.findOne({ unameLower: uname.toLowerCase() }), - ]); + const current = await collV.findOne({ unameLower: uname.toLowerCase() }); if (current) return current._id; - const uid = min?._id ? min._id - 1 : -1000; - await collV.insertOne({ - _id: uid, - mail: `${-uid}@vuser.local`, - mailLower: `${-uid}@vuser.local`, - uname, - unameLower: uname.trim().toLowerCase(), - hash: '', - salt: '', - hashType: 'hydro', - regat: new Date(), - ip: ['127.0.0.1'], - loginat: new Date(), - loginip: '127.0.0.1', - priv: 0, - }); - return uid; + return UserModel.createVuser(uname); + } + + @ArgMethod + static async createVuser(uname: string, extra: Record = {}) { + const [min] = await collV.find({}).sort({ _id: 1 }).limit(1).toArray(); + let uid = min?._id ? min._id - 1 : -1000; + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + await collV.insertOne({ + ...extra, + _id: uid, + mail: `${-uid}@vuser.local`, + mailLower: `${-uid}@vuser.local`, + uname, + unameLower: uname.trim().toLowerCase(), + hash: '', + salt: '', + hashType: 'hydro', + regat: new Date(), + ip: ['127.0.0.1'], + loginat: new Date(), + loginip: '127.0.0.1', + priv: 0, + }); + return uid; + } catch (e) { + // Duplicate _id from a concurrent createVuser/ensureVuser: pick the next slot. + if (e?.code === 11000 && JSON.stringify(e.keyPattern) === '{"_id":1}') { + uid -= 1; + continue; + } + throw e; + } + } } static getMulti(params: Filter = {}, projection?: (keyof Udoc)[]) { diff --git a/packages/onsite-toolkit/submit.ts b/packages/onsite-toolkit/submit.ts index 691164fbd..985b8f31f 100644 --- a/packages/onsite-toolkit/submit.ts +++ b/packages/onsite-toolkit/submit.ts @@ -73,7 +73,7 @@ Language ${SettingModel.langs[lang].display} (${lang}) await Promise.all([ ProblemModel.inc(domainId, this.pdoc.docId, 'nSubmit', 1), DomainModel.incUserInDomain(domainId, this.user._id, 'nSubmit'), - ContestModel.updateStatus(domainId, this.tdoc.docId, this.user._id, rid, this.pdoc.docId), + ContestModel.updateStatus(domainId, this.tdoc.docId, this.team ?? this.user._id, rid, this.pdoc.docId), ]); return { rid }; } diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html index 833f8b2d5..dc55325c9 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -174,6 +174,15 @@

{{ _('Contest Settings') }}

value:tdoc.allowPrint|default(false), row:false }) }} + {{ form.form_checkbox({ + columns:4, + label:'Allow Team', + name:'allowTeam', + placeholder:_('Allow team participation (users register as a team at /contest/team)'), + value:tdoc.allowTeam|default(false), + row:false, + disabled:tdoc.allowTeam|default(false) + }) }}