From 15d43636c0c0e5174f2de43b0a69fcc1bd2f74e2 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 27 Apr 2020 12:18:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8A=9F=E8=83=BD=EF=BC=9Apr?= =?UTF-8?q?etest=20discuss=20user=20dashboard=20statistics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 3 +- .gitignore | 4 +- .vscode/settings.json | 3 +- build/index.js | 11 + build/locales.js | 16 ++ build/root.js | 5 + build/webpack.js | 40 +++ hydro/development.js | 29 +- hydro/error.js | 159 +++++------ hydro/handler/discussion.js | 259 ++++++++++++++++++ hydro/handler/import.js | 2 +- hydro/handler/judge.js | 66 +++-- hydro/handler/manage.js | 112 ++++++++ hydro/handler/problem.js | 148 ++++------ hydro/handler/record.js | 1 + hydro/handler/user.js | 2 +- hydro/lib/i18n.js | 11 +- hydro/lib/md5.js | 2 +- hydro/model/discussion.js | 149 ++++++++++ hydro/model/solution.js | 15 +- hydro/model/system.js | 39 +-- hydro/model/training.js | 4 + hydro/model/user.js | 9 +- hydro/options.js | 1 + hydro/service/db.js | 3 +- hydro/service/server.js | 16 +- package.json | 6 +- templates/discussion_create.html | 2 +- .../domain_manage_join_applications.html | 60 ---- templates/domain_manage_permission.html | 2 +- templates/domain_manage_role.html | 7 +- templates/partials/discussion_edit_form.html | 26 +- templates/partials/discussion_list.html | 84 +++--- templates/partials/problem_lucky.html | 16 +- templates/partials/problem_stat.html | 2 +- templates/partials/training_default.json | 4 +- templates/wiki_help.html | 17 +- .../scratchpad/ScratchpadPretestContainer.js | 28 -- .../ScratchpadRecordsRowContainer.js | 2 +- ui/components/scratchpad/reducers/pretest.js | 62 ----- webpack.config.js | 16 -- 41 files changed, 920 insertions(+), 523 deletions(-) create mode 100644 build/index.js create mode 100644 build/locales.js create mode 100644 build/root.js create mode 100644 build/webpack.js create mode 100644 hydro/handler/discussion.js create mode 100644 hydro/handler/manage.js create mode 100644 hydro/model/discussion.js delete mode 100644 templates/domain_manage_join_applications.html delete mode 100644 webpack.config.js diff --git a/.eslintrc.js b/.eslintrc.js index 4595c473a..8327613bc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { "radix": "off", "guard-for-in": "off", "no-param-reassign": "off", - "global-require": "off" + "global-require": "off", + "no-nested-ternary": "off", }, }; diff --git a/.gitignore b/.gitignore index b66862323..46fe8fa78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ node_modules/ config.yaml +.build/ .uibuild/ __* ui/misc/.iconfont ui/static/locale -.webpackStats.json .cache -dist/ +.webpackStats.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f2074466..c85abac09 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { + "files.autoGuessEncoding": false, "files.autoSave": "onFocusChange", "files.encoding": "utf8", - "files.autoGuessEncoding": false, + "files.eol": "\n", "editor.detectIndentation": false, "editor.formatOnSave": false } \ No newline at end of file diff --git a/build/index.js b/build/index.js new file mode 100644 index 000000000..5390bbb6a --- /dev/null +++ b/build/index.js @@ -0,0 +1,11 @@ +const fs = require('fs'); +const root = require('./root'); +try { + fs.rmdirSync(root('.build')); +} catch (e) { } +fs.mkdirSync(root('.build')); +let progress = (name) => ((prog) => { + console.log(name, prog); +}); +require('./locales')('locales', progress); +require('./webpack')('webpack', progress); diff --git a/build/locales.js b/build/locales.js new file mode 100644 index 000000000..a6c64e2b9 --- /dev/null +++ b/build/locales.js @@ -0,0 +1,16 @@ +const yaml = require('js-yaml'); +const fs = require('fs'); +const root = require('./root'); +const build = async (next) => { + let langs = fs.readdirSync(root('locales')); + next({ total: langs.length }); + let lang = {}; + let count = 0; + for (let i of langs) { + const content = fs.readFileSync(root(`locales/${i}`)).toString(); + lang[i.split('.')[0]] = yaml.safeLoad(content); + next({ progress: ++count }); + } + fs.writeFileSync(root('.build/locales.json'), JSON.stringify(lang)); +} +module.exports = build; diff --git a/build/root.js b/build/root.js new file mode 100644 index 000000000..19453df88 --- /dev/null +++ b/build/root.js @@ -0,0 +1,5 @@ +const path = require('path'); +function root(name) { + return path.resolve(__dirname, '..', name); +} +module.exports = root; diff --git a/build/webpack.js b/build/webpack.js new file mode 100644 index 000000000..34e4f33f8 --- /dev/null +++ b/build/webpack.js @@ -0,0 +1,40 @@ +const path = require('path'); +const fs = require('fs'); +const webpack = require('webpack'); +const root = require('./root'); +const build = async (next) => { + const modules = fs.readdirSync(root('hydro', 'module')); + const config = { + mode: 'production', + entry: { + development: root('hydro/development.js'), + install: root('hydro/install.js'), + uninstall: root('hydro/uninstall.js') + }, + output: { + filename: '[name].js', + path: root('.build') + }, + target: 'node', + module: {} + }; + for (let i of modules) { + if (fs.statSync(path.resolve(__dirname, 'hydro', 'module', i)).isDirectory()) { + config.entry[root(`.build/module/${i}`)] = root(`./hydro/module/${i}/index.js`); + } else { + config.entry[root(`.build/module/${i}`)] = root(`./hydro/module/${i}`); + } + } + const compiler = webpack(config); + function compilerCallback(err, stats) { + if (err) { + console.error(err.stack || err); + if (err.details) console.error(err.details); + process.exit(1); + } + } + if (!watch && stats.hasErrors()) process.exitCode = 1; + compiler.run(compilerCallback); + next({ total: 100 }); +} +module.exports = build; diff --git a/hydro/development.js b/hydro/development.js index 0a9571903..0228587e0 100644 --- a/hydro/development.js +++ b/hydro/development.js @@ -8,25 +8,24 @@ process.stdin.on('data', async (input) => { console.warn(e); } }); -const path = require('path'); -const EventEmitter = require('events'); -const i18n = require('./lib/i18n'); -i18n(path.resolve(__dirname, '..', 'locales', 'zh_CN.yaml'), 'zh_CN'); -i18n(path.resolve(__dirname, '..', 'locales', 'zh_TW.yaml'), 'zh_TW'); -i18n(path.resolve(__dirname, '..', 'locales', 'en.yaml'), 'en'); +require('./lib/i18n'); +require('./utils'); + +const bus = require('./service/bus'); -global.bus = new EventEmitter(); async function run() { - require('./utils'); - require('./service/db'); await new Promise((resolve) => { - global.bus.once('connected', () => { + const h = () => { console.log('Database connected'); + bus.unsubscribe(['system_database_connected'], h); resolve(); - }); + }; + bus.subscribe(['system_database_connected'], h); + require('./service/db'); }); require('./service/gridfs'); + require('./service/queue'); const server = require('./service/server'); require('./handler/home'); require('./handler/problem'); @@ -38,14 +37,6 @@ async function run() { require('./handler/import'); server.start(); } -process.on('restart', async () => { - console.log('Signal detected, restarting...'); - await global.Hydro.stop(); - await global.Hydro.destory(); - delete global.Hydro; - delete require.cache; - run(); -}); run().catch((e) => { console.error(e); process.exit(1); diff --git a/hydro/error.js b/hydro/error.js index 43fec6cfd..4275f6cb6 100644 --- a/hydro/error.js +++ b/hydro/error.js @@ -6,6 +6,7 @@ class UserFacingError extends Error { this.params = []; } } + class SystemError extends Error { constructor(type) { super(type); @@ -14,70 +15,89 @@ class SystemError extends Error { this.params = []; } } + class BadRequestError extends UserFacingError { constructor(type) { super(type); this.code = 400; } } + class ForbiddenError extends UserFacingError { constructor(type) { super(type); this.code = 403; } } + class NotFoundError extends UserFacingError { constructor(type) { super(type || 'NotFoundError'); this.code = 404; } } + class RemoteOnlineJudgeError extends UserFacingError { constructor(message) { super('RemoteOnlineJudgeError'); this.params = [message]; } } + class AlreadyVotedError extends BadRequestError { constructor(psid, uid) { super('You\'ve already voted.'); this.params = [psid, uid]; } } + class LoginError extends ForbiddenError { constructor(uname) { super('LoginError'); this.params = [uname]; } } + class UserAlreadyExistError extends ForbiddenError { constructor(uname) { super('UserAlreadyExistError'); this.params = [uname]; } } + class InvalidTokenError extends ForbiddenError { constructor(token) { super('InvalidTokenError'); this.params = [token]; } } + +class BlacklistedError extends ForbiddenError { + constructor(ip) { + super('Address {0} is blacklisted.'); + this.params = [ip]; + } +} + class UserNotFoundError extends NotFoundError { constructor(user) { super('UserNotFoundError'); this.params = [user]; } } + class NoProblemError extends NotFoundError { constructor() { super('NoProblemError'); } } + class VerifyPasswordError extends ForbiddenError { constructor() { super('VerifyPasswordError'); } } + class OpcountExceededError extends ForbiddenError { constructor(op, periodSecs, maxOperations) { super('OpcountExceededError'); @@ -91,6 +111,7 @@ class PermissionError extends ForbiddenError { this.params = [perm]; } } + class ValidationError extends ForbiddenError { constructor(field0, field1) { super('ValidationError'); @@ -98,66 +119,84 @@ class ValidationError extends ForbiddenError { else this.params = [field0, field1]; } } + class ContestNotAttendedError extends ForbiddenError { constructor(tid) { super('You haven\'t attended this contest yet.'); this.params = [tid]; } } + class ContestAlreadyAttendedError extends ForbiddenError { constructor(tid, uid) { super('You\'ve already attended this contest.'); this.params = [tid, uid]; } } + class ContestNotLiveError extends ForbiddenError { constructor(tid) { super('This contest is not live.'); this.params = [tid]; } } + class ContestScoreboardHiddenError extends ForbiddenError { constructor(tid) { super('Contest scoreboard is not visible.'); this.params = [tid]; } } + class TrainingAlreadyEnrollError extends ForbiddenError { constructor(tid, uid) { super("You've already enrolled this training."); this.params = [tid, uid]; } } + +class RoleAlreadyExistError extends ForbiddenError { + constructor(role) { + super('This role already exists.'); + this.params = [role]; + } +} + class ProblemNotFoundError extends NotFoundError { constructor(pid) { super('ProblemNotFoundError'); this.params = [pid]; } } + class RecordNotFoundError extends NotFoundError { constructor(rid) { super('RecordNotFoundError'); this.params = [rid]; } } + class SolutionNotFoundError extends NotFoundError { constructor(psid) { super('SolutionNotFoundError'); this.params = [psid]; } } + class TrainingNotFoundError extends NotFoundError { constructor(tid) { super('TrainingNotFoundError'); this.params = [tid]; } } + class ContestNotFoundError extends NotFoundError { constructor(cid) { super('ContestNotFoundError'); this.params = [cid]; } } + class ProblemDataNotFoundError extends NotFoundError { constructor(pid) { super('Data of problem {0} not found.'); @@ -165,8 +204,30 @@ class ProblemDataNotFoundError extends NotFoundError { } } +class DiscussionNodeNotFoundError extends NotFoundError { + constructor(type, docId) { + super('Discussion node {0}/{1} not found.'); + this.params = [type, docId]; + } +} + +class DocumentNotFoundError extends NotFoundError { + constructor(docId) { + super('Document {0} not found.'); + this.params = [docId]; + } +} + +class DiscussionNotFoundError extends NotFoundError { + constructor(did) { + super('Discussion {0} not found.'); + this.params = [did]; + } +} + module.exports = { BadRequestError, + BlacklistedError, ForbiddenError, NotFoundError, LoginError, @@ -193,49 +254,13 @@ module.exports = { SystemError, TrainingAlreadyEnrollError, RemoteOnlineJudgeError, + DiscussionNodeNotFoundError, + DocumentNotFoundError, + DiscussionNotFoundError, + RoleAlreadyExistError, }; /* -from vj4.model import builtin - - -class Error(Exception): - pass - - -class HashError(Error): - pass - - -class InvalidStateError(Error): - pass - - -class UserFacingError(Error): - """Error which faces end user.""" - - def to_dict(self): - return {'name': self.__class__.__name__, 'args': self.args} - - @property - def http_status(self): - return 500 - - @property - def template_name(self): - return 'error.html' - - @property - def message(self): - return 'An error has occurred.' - - -class BlacklistedError(ForbiddenError): - @property - def message(self): - return 'Address {0} is blacklisted.' - - class FileTooLongError(ValidationError): @property def message(self): @@ -253,13 +278,6 @@ class UnknownFieldError(ForbiddenError): def message(self): return 'Unknown field {0}.' - -class DocumentNotFoundError(NotFoundError): - @property - def message(self): - return 'Document {2} not found.' - - class CsrfTokenError(ForbiddenError): pass @@ -296,54 +314,12 @@ class DiscussionNodeAlreadyExistError(ForbiddenError): return 'Discussion node {1} already exists.' -class DiscussionNodeNotFoundError(NotFoundError): - @property - def message(self): - return 'Discussion node {1} not found.' - - -class DiscussionNotFoundError(DocumentNotFoundError): - @property - def message(self): - return 'Discussion {1} not found.' - - class MessageNotFoundError(NotFoundError): @property def message(self): return 'Message {0} not found.' -class InvalidJoinInvitationCodeError(ForbiddenError): - @property - def message(self): - return 'The invitation code you provided is invalid.' - - -class HomeworkScoreboardHiddenError(ForbiddenError): - @property - def message(self): - return 'Homework scoreboard is not visible.' - - -class HomeworkNotLiveError(ForbiddenError): - @property - def message(self): - return 'This homework is not open.' - - -class HomeworkAlreadyAttendedError(ForbiddenError): - @property - def message(self): - return "You've already claimed this homework." - - -class HomeworkNotAttendedError(ForbiddenError): - @property - def message(self): - return "You haven't claimed this homework yet." - - class TrainingRequirementNotSatisfiedError(ForbiddenError): @property def message(self): @@ -355,11 +331,6 @@ class UsageExceededError(ForbiddenError): def message(self): return 'Usage exceeded.' -class ModifyBuiltinRoleError(ForbiddenError): - @property - def message(self): - return 'Built-in roles cannot be modified.' - class InvalidArgumentError(BadRequestError): @property diff --git a/hydro/handler/discussion.js b/hydro/handler/discussion.js new file mode 100644 index 000000000..b454b9dde --- /dev/null +++ b/hydro/handler/discussion.js @@ -0,0 +1,259 @@ +const paginate = require('../lib/paginate'); +const problem = require('../model/problem'); +const contest = require('../model/contest'); +const user = require('../model/user'); +const discussion = require('../model/discussion'); +const { + Route, Handler, +} = require('../service/server'); +const { + DiscussionNodeNotFoundError, DiscussionNotFoundError, DocumentNotFoundError, +} = require('../error'); +const { constants } = require('../options'); +const { + PERM_VIEW_DISCUSSION, PERM_EDIT_DISCUSSION, PERM_EDIT_DISCUSSION_REPLY, + PERM_VIEW_PROBLEM_HIDDEN, PERM_DELETE_DISCUSSION, PERM_DELETE_DISCUSSION_REPLY, + PERM_HIGHLIGHT_DISCUSSION, PERM_LOGGEDIN, PERM_CREATE_DISCUSSION, + PERM_REPLY_DISCUSSION, +} = require('../permission'); + +class DiscussionHandler extends Handler { + async _prepare({ + type, docId, did, drid, drrid, + }) { + this.checkPerm(PERM_VIEW_DISCUSSION); + if (did) { + this.ddoc = await discussion.get(did); + if (!this.ddoc) throw new DiscussionNotFoundError(did); + type = this.ddoc.type; + docId = this.ddoc.parent; + if (drid) { + this.drdoc = await discussion.getReply(drid, did); + if (!this.drdoc) throw new DiscussionNotFoundError(drid); + if (this.drdoc.parent !== this.ddoc._id) throw new DocumentNotFoundError(drid); + if (drrid) { + [, this.drrdoc] = await discussion.getTailReply(drid, drrid); + if (!this.drrdoc) throw new DiscussionNotFoundError(drrid); + if (this.drrdoc.parent !== this.drdoc._id) throw new DocumentNotFoundError(drid); + } + } + } + // TODO(twd2): do more visibility check eg. contest + // TODO(twd2): exclude problem/contest discussions? + // TODO(iceboy): continuation based pagination. + if (type && docId) { + if (type === 'problem') { + const pdoc = await problem.getById(docId); + if (!pdoc) throw new DiscussionNodeNotFoundError(type, docId); + if (pdoc.hidden) this.checkPerm(PERM_VIEW_PROBLEM_HIDDEN); + this.title = pdoc.title; + } else if (type === 'contest') { + const tdoc = await contest.get(docId); + if (!tdoc) throw new DiscussionNodeNotFoundError(type, docId); + this.title = tdoc.title; + } else throw new DiscussionNodeNotFoundError(type, docId); + } + } +} + +class DiscussionMainHandler extends DiscussionHandler { + async get({ page = 1 }) { + const [ddocs, dpcount] = await paginate(discussion.getMulti(), page, constants.DISCUSSION_PER_PAGE); + const udict = await user.getList(ddocs.map((ddoc) => ddoc.owner)); + this.response.template = 'discussion_main_or_node.html'; + this.response.body = { + ddocs, dpcount, udict, page, + }; + } +} + +class DiscussionNodeHandler extends DiscussionHandler { + async get({ type, docId, page = 1 }) { + const [ddocs, dpcount] = await paginate( + discussion.getMulti({ type, docId }), + page, + constants.DISCUSSION_PER_PAGE, + ); + const udict = await user.getList(ddocs.map((ddoc) => ddoc.owner)); + const path = [ + ['discussion_main', '/discuss'], + [this.title, null, true], + ]; + this.response.template = 'discussion_main_or_node.html'; + this.response.body = { + ddocs, dpcount, udict, path, page, + }; + } +} + +class DiscussionCreateHandler extends DiscussionHandler { + async prepare() { + this.checkPerm(PERM_LOGGEDIN); + this.checkPerm(PERM_CREATE_DISCUSSION); + } + + async get({ type, docId }) { + const path = [ + ['discussion_main', '/discuss'], + [this.title, `/discuss/${type}/${docId}`, true], + ['discussion_create', null], + ]; + this.response.template = 'discussion_create.html'; + this.response.body = { path, title: this.title }; + } + + async post({ + type, docId, title, content, highlight, + }) { + this.limitRate('add_discussion', 3600, 30); + const flags = {}; + if (highlight) { + this.checkPerm(PERM_HIGHLIGHT_DISCUSSION); + flags.highlight = true; + } + const did = await discussion.add(type, docId, this.user._id, title, content, this.request.ip, flags); + this.response.body = { did }; + this.response.redirect = `/discuss/${did}`; + } +} + +class DiscussionDetailHandler extends DiscussionHandler { + async get({ did, page = 1 }) { + const dsdoc = this.user.hasPerm(PERM_LOGGEDIN) + ? await discussion.getStatus(did, this.user._id) + : null; + const [drdocs, pcount, drcount] = await paginate( + discussion.getMultiReply(did), + page, + constants.REPLY_PER_PAGE, + ); + const uids = drdocs.map((drdoc) => drdoc.owner); + uids.push(this.ddoc.owner); + for (const drdoc of drdocs) { + if (drdoc.reply) { + for (const drrdoc of drdocs) { + uids.push(drrdoc.owner); + } + } + } + const udict = await user.getList(uids); + const path = [ + ['discussion_main', '/discuss'], + [this.title, '/discuss/{type}', true], + [this.ddoc.title, null, true], + ]; + this.response.template = 'discussion_detail.html'; + this.response.body = { + path, ddoc: this.ddoc, dsdoc, drdocs, page, pcount, drcount, udict, + }; + } + + async postReply({ did, content }) { + this.checkPerm(PERM_LOGGEDIN); + this.checkPerm(PERM_REPLY_DISCUSSION); + this.limitRate('add_discussion', 3600, 30); + await discussion.addReply(did, this.user._id, content, this.request.ip); + this.back(); + } + + async postTailReply({ drid, content }) { + this.checkPerm(PERM_LOGGEDIN); + this.checkPerm(PERM_REPLY_DISCUSSION); + this.limitRate('add_discussion', 3600, 30); + await discussion.addTailReply(drid, this.user._id, content, this.request.ip); + this.back(); + } + + async postEditReply({ drid, content }) { + if (this.drdoc.owner !== this.user._id) this.checkPerm(PERM_EDIT_DISCUSSION_REPLY); + await discussion.editReply(drid, content); + this.back(); + } + + async postDeleteReply({ drid }) { + if (this.drdoc.owner !== this.user._id) this.checkPerm(PERM_DELETE_DISCUSSION_REPLY); + await discussion.deleteReply(drid); + this.back(); + } + + async postEditTailReply({ drid, drrid, content }) { + if (this.drdoc.owner !== this.user._id) this.checkPerm(PERM_EDIT_DISCUSSION_REPLY); + await discussion.editTailReply(drid, drrid, content); + this.back(); + } + + async postDeleteTailReply({ drid, drrid }) { + if (this.drrdoc.owner !== this.user._id) this.checkPerm(PERM_DELETE_DISCUSSION_REPLY); + await discussion.deleteTailReply(drid, drrid); + this.back(); + } + + async postStar({ did, star }) { + await discussion.setStar(did, this.user._id, true); + this.response.body = { star }; + this.response.direct = this.request.path; + } + + async postUnstar({ did, star }) { + await discussion.setStar(did, this.user._id, false); + this.response.body = { star }; + this.response.direct = this.request.path; + } +} + +class DiscussionDetailRawHandler extends DiscussionHandler { + async get() { + this.response.type = 'text/markdown'; + this.response.body = this.ddoc.content; + } +} + +class DiscussionReplyRawHandler extends DiscussionHandler { + async get() { + this.response.type = 'text/markdown'; + this.response.body = this.drdoc.content; + } +} + + +class DiscussionTailReplyRawHandler extends DiscussionHandler { + async get() { + this.response.type = 'text/markdown'; + this.response.body = this.drrdoc.content; + } +} + +class DiscussionEditHandler extends DiscussionHandler { + async get() { + if (this.ddoc.owner !== this.user._id) this.checkPerm(PERM_EDIT_DISCUSSION); + this.response.template = 'discussion_edit.html'; + this.response.body = { ddoc: this.ddoc }; + } + + async post({ + did, title, content, highlight, operation, + }) { + if (operation) return; + if (this.ddoc.owner !== this.user._id) this.checkPerm(PERM_EDIT_DISCUSSION); + if (highlight && !this.ddoc.highlight) this.checkPerm(PERM_HIGHLIGHT_DISCUSSION); + await discussion.edit(did, title, content, highlight); + this.response.body = { did }; + this.response.redirect = `/discuss/${did}`; + } + + async postDelete({ did }) { + if (this.ddoc.owner !== this.user._id) this.checkPerm(PERM_DELETE_DISCUSSION); + await discussion.delete(did); + this.response.body = { type: this.ddoc.type, parent: this.ddoc.parent }; + this.response.redirect = `/discuss/${this.ddoc.type}/${this.ddoc.parent}`; + } +} + +Route('/discuss', DiscussionMainHandler); +Route('/discuss/:did', DiscussionDetailHandler); +Route('/discuss/:did/edit', DiscussionEditHandler); +Route('/discuss/:did/raw', DiscussionDetailRawHandler); +Route('/discuss/:did/:drid/raw', DiscussionReplyRawHandler); +Route('/discuss/:did/:drid/:drrid/raw', DiscussionTailReplyRawHandler); +Route('/discuss/:type/:docId', DiscussionNodeHandler); +Route('/discuss/:type/:docId/create', DiscussionCreateHandler); diff --git a/hydro/handler/import.js b/hydro/handler/import.js index f829ef9ed..1d727945a 100644 --- a/hydro/handler/import.js +++ b/hydro/handler/import.js @@ -8,7 +8,7 @@ const { ValidationError, RemoteOnlineJudgeError } = require('../error'); class ProblemImportHandler extends Handler { async syzoj(url) { - const RE_SYZOJ = /https?:\/\/([a-zA-Z0-9\.]+)\/problem\/([0-9]+)\/?/i; + const RE_SYZOJ = /https?:\/\/([a-zA-Z0-9.]+)\/problem\/([0-9]+)\/?/i; assert(url.match(RE_SYZOJ), new ValidationError('url')); if (!url.endsWith('/')) url += '/'; const [, host, pid] = RE_SYZOJ.exec(url); diff --git a/hydro/handler/judge.js b/hydro/handler/judge.js index 4ef85a0a8..16d177b85 100644 --- a/hydro/handler/judge.js +++ b/hydro/handler/judge.js @@ -96,16 +96,29 @@ class JudgeHandler extends Handler { let rid = await queue.get('judge', false); while (rid) { const rdoc = await record.get(rid); // eslint-disable-line no-await-in-loop - const pdoc = await problem.getById(rdoc.pid); // eslint-disable-line no-await-in-loop - const task = { - event: 'judge', - rid, - type: 0, - pid: rdoc.pid, - data: pdoc.data, - lang: rdoc.lang, - code: rdoc.code, - }; + let task; + if (!rdoc.input) { + // eslint-disable-next-line no-await-in-loop + const pdoc = await problem.getById(rdoc.pid); + task = { + event: 'judge', + rid, + type: 0, + pid: rdoc.pid, + data: pdoc.data, + lang: rdoc.lang, + code: rdoc.code, + }; + } else { + task = { + event: 'pretest', + rid, + type: 0, + input: rdoc.input, + lang: rdoc.lang, + code: rdoc.code, + }; + } tasks.push(task); rid = await queue.get('judge', false); // eslint-disable-line no-await-in-loop } @@ -134,16 +147,29 @@ class JudgeConnectionHandler extends ConnectionHandler { this.processing = null; const rid = await queue.get('judge'); const rdoc = await record.get(rid); - const pdoc = await problem.getById(rdoc.pid); - const task = { - event: 'judge', - rid, - type: 0, - pid: rdoc.pid, - data: pdoc.data, - lang: rdoc.lang, - code: rdoc.code, - }; + let task; + if (!rdoc.input) { + // eslint-disable-next-line no-await-in-loop + const pdoc = await problem.getById(rdoc.pid); + task = { + event: 'judge', + rid, + type: 0, + pid: rdoc.pid, + data: pdoc.data, + lang: rdoc.lang, + code: rdoc.code, + }; + } else { + task = { + event: 'pretest', + rid, + type: 0, + input: rdoc.input, + lang: rdoc.lang, + code: rdoc.code, + }; + } this.send({ task }); this.processing = task.rid; } diff --git a/hydro/handler/manage.js b/hydro/handler/manage.js new file mode 100644 index 000000000..af7253536 --- /dev/null +++ b/hydro/handler/manage.js @@ -0,0 +1,112 @@ +const system = require('../model/system'); +const user = require('../model/user'); +const builtin = require('../model/builtin'); +const { Route, Handler } = require('../service/server'); +const { PERM_ADMIN } = require('../permission'); +const { RoleAlreadyExistError } = require('../error'); + +class ManageHandler extends Handler { + async prepare() { + this.checkPerm(PERM_ADMIN); + } +} + +class ManageMainHandler extends ManageHandler { + async get() { + this.response.redirect = '/manage/dashboard'; + } +} + +class ManageDashboardHandler extends ManageHandler { + async get() { + this.response.template = 'domain_manage_dashboard.html'; + } +} + +class ManageEditHandler extends ManageHandler { + async get() { + this.response.template = 'domain_manage_edit.html'; + } + + async post({ name, gravatar, bulletin }) { + await Promise.all([ + system.set('name', name), + system.set('gravatar', gravatar), + system.set('bulletin', bulletin), + ]); + this.back(); + } +} + +class ManageUserHandler extends ManageHandler { + async get() { + const uids = []; + const [rudocs, udocs, roles] = await Promise.all([ + system.get(), + user.getMulti({ role: { $neq: 'default' } }).toArray(), + user.getRoles(), + ]); + for (const udoc of udocs) { + uids.push(udoc._id); + rudocs[udoc.role].push(udoc); + } + const roles_with_text = roles.map((role) => [role, role]); + const udict = await user.getList(uids); + this.response.template = 'domain_manage_user.html'; + this.response.body = { + roles, roles_with_text, rudocs, udict, + }; + } + + async postSetUser({ uid, role }) { + await user.setById(uid, { role }); + this.back(); + } +} + +class ManagePermissionHandler extends ManageHandler { + async get() { + const roles = await user.getRoles(); + this.response.template = 'domain_manage_permission.html'; + this.response.body = { roles }; + } + + async post({ roles }) { + for (const role of roles) { + let perms = ''; + for (const perm of roles) { + perms += perm; + } + roles[role] = perms; + } + await user.setRoles(roles); + this.back(); + } +} + +class ManageRoleHandler extends ManageHandler { + async get() { + const roles = await user.getRoles(); + this.response.template = 'domain_manage_role.html'; + this.response.body = { roles }; + } + + async postAdd({ role }) { + const roles = await user.getRoles(); + if (roles.includes(role)) throw new RoleAlreadyExistError(role); + await user.setRole(role, builtin.PERM_DEFAULT); + this.back(); + } + + async postDelete({ role }) { + await user.deleteRoles(role); + this.back(); + } +} + +Route('/manage', ManageMainHandler); +Route('/manage/dashboard', ManageDashboardHandler); +Route('/manage/edit', ManageEditHandler); +Route('/manage/user', ManageUserHandler); +Route('/manage/permission', ManagePermissionHandler); +Route('/manage/role', ManageRoleHandler); diff --git a/hydro/handler/problem.js b/hydro/handler/problem.js index 72ede39f6..91187c6ac 100644 --- a/hydro/handler/problem.js +++ b/hydro/handler/problem.js @@ -6,7 +6,10 @@ const record = require('../model/record'); const user = require('../model/user'); const solution = require('../model/solution'); const system = require('../model/system'); -const { Route, Handler } = require('../service/server'); +const bus = require('../service/bus'); +const { + Route, Connection, Handler, ConnectionHandler, +} = require('../service/server'); const gridfs = require('../service/gridfs'); const queue = require('../service/queue'); const { @@ -123,6 +126,48 @@ class ProblemSubmitHandler extends ProblemDetailHandler { } } +class ProblemPretestHandler extends ProblemDetailHandler { + async post({ lang, code, input }) { + this.limitRate('add_record', 60, 100); + const rid = await record.add({ + uid: this.uid, lang, code, pid: this.pdoc._id, input, + }); + await queue.push('judge', rid); + this.response.body = { rid }; + } +} + +class ProblemPretestConnectionHandler extends ConnectionHandler { + async prepare({ pid }) { + this.pid = pid.toString(); + bus.subscribe(['record_change'], this.onRecordChange); + } + + async onRecordChange(data) { + const rdoc = data.value; + if (rdoc.uid !== this.user._id || rdoc.pid.toString() !== this.pid) return; + if (rdoc.tid) return; + this.send({ rdoc }); + } + + async cleanup() { + bus.unsubscribe(['record_change'], this.onRecordChange); + } +} + +class ProblemStatisticsHandler extends ProblemDetailHandler { + async get() { + const udoc = await user.getById(this.pdoc.owner); + const path = [ + ['problem_main', '/p'], + [this.pdoc.title, `/p/${this.pdoc.pid}`, true], + ['problem_statistics', null], + ]; + this.response.template = 'problem_statistics.html'; + this.response.body = { pdoc: this.pdoc, udoc, path }; + } +} + class ProblemManageHandler extends ProblemDetailHandler { async prepare() { if (this.pdoc.owner !== this.uid) this.checkPerm(PERM_EDIT_PROBLEM); @@ -322,7 +367,7 @@ class ProblemCreateHandler extends Handler { title, pid, content, hidden, }) { validator.checkPid(pid); - pid = pid || await system.incPidCounter(); + pid = pid || await system.inc('pid'); await problem.add({ title, content, owner: this.user._id, pid, hidden, }); @@ -335,7 +380,9 @@ Route('/p', ProblemHandler); Route('/problem/random', ProblemRandomHandler); Route('/p/:pid', ProblemDetailHandler); Route('/p/:pid/submit', ProblemSubmitHandler); +Route('/p/:pid/pretest', ProblemPretestHandler); Route('/p/:pid/settings', ProblemSettingsHandler); +Route('/p/:pid/statistics', ProblemStatisticsHandler); Route('/p/:pid/edit', ProblemEditHandler); Route('/p/:pid/upload', ProblemDataUploadHandler); Route('/p/:pid/data', ProblemDataDownloadHandler); @@ -343,99 +390,4 @@ Route('/p/:pid/solution', ProblemSolutionHandler); Route('/p/:pid/solution/:psid/raw', ProblemSolutionRawHandler); Route('/p/:pid/solution/:psid/:psrid/raw', ProblemSolutionReplyRawHandler); Route('/problem/create', ProblemCreateHandler); - - -/* - -@app.route('/p/{pid}/pretest', 'problem_pretest') -class ProblemPretestHandler(base.Handler): - @base.requirePerm(builtin.PERM_SUBMIT_PROBLEM) - @base.route_argument - @base.post_argument - @base.require_csrfToken - @base.sanitize - @base.limitRate('add_record', 60, 100) - async def post(self, *, pid: document.convert_doc_id, lang: str, code: str, - data_input: str, data_output: str): - pdoc = await problem.get(self.domainId, pid) - # don't need to check hidden status - # create zip file, TODO(twd2): check file size - post = await self.request.post() - content = list(zip(post.getall('data_input'), post.getall('data_output'))) - output_buffer = io.BytesIO() - zip_file = zipfile.ZipFile(output_buffer, 'a', zipfile.ZIP_DEFLATED) - config_content = str(len(content)) + '\n' - for i, (data_input, data_output) in enumerate(content): - input_file = 'input{0}.txt'.format(i) - output_file = 'output{0}.txt'.format(i) - config_content += '{0}|{1}|1|10|262144\n'.format(input_file, output_file) - zip_file.writestr('Input/{0}'.format(input_file), data_input) - zip_file.writestr('Output/{0}'.format(output_file), data_output) - zip_file.writestr('Config.ini', config_content) - # mark all files as created in Windows :p - for zfile in zip_file.filelist: - zfile.create_system = 0 - zip_file.close() - fid = await fs.add_data('application/zip', output_buffer.getvalue()) - output_buffer.close() - rid = await record.add(self.domainId, pdoc['_id'], constant.record.TYPE_PRETEST, - self.user['_id'], lang, code, fid) - self.json_or_redirect(self.reverse_url('record_detail', rid=rid)) - - -@app.connection_route('/p/{pid}/pretest-conn', 'problem_pretest-conn') -class ProblemPretestConnection(record_handler.RecordVisibilityMixin, base.Connection): - async def on_open(self): - await super(ProblemPretestConnection, self).on_open() - self.pid = document.convert_doc_id(self.request.match_info['pid']) - bus.subscribe(self.on_record_change, ['record_change']) - - async def on_record_change(self, e): - rdoc = e['value'] - if rdoc['uid'] != self.user['_id'] or \ - rdoc['domainId'] != self.domainId or rdoc['pid'] != self.pid: - return - # check permission for visibility: contest - if rdoc['tid']: - show_status, tdoc = await self.rdoc_contest_visible(rdoc) - if not show_status: - return - self.send(rdoc=rdoc) - - async def on_close(self): - bus.unsubscribe(self.on_record_change) - - - @base.requirePriv(builtin.PRIV_USER_PROFILE) - @base.route_argument - @base.post_argument - @base.require_csrfToken - @base.sanitize - async def post(self, *, pid: document.convert_doc_id, title: str, content: str): - pdoc = await problem.get(self.domainId, pid) - if not self.own(pdoc, builtin.PERM_EDIT_PROBLEM_SELF): - self.check_perm(builtin.PERM_EDIT_PROBLEM) - await problem.edit(self.domainId, pdoc['_id'], title=title, content=content) - self.json_or_redirect(self.reverse_url('problem_detail', pid=pid)) - - -@app.route('/p/{pid}/statistics', 'problem_statistics') -class ProblemStatisticsHandler(base.Handler): - @base.route_argument - @base.sanitize - async def get(self, *, pid: document.convert_doc_id): - # TODO(twd2) - uid = self.user['_id'] if self.has_priv(builtin.PRIV_USER_PROFILE) else None - pdoc = await problem.get(self.domainId, pid, uid) - if pdoc.get('hidden', False): - self.check_perm(builtin.PERM_VIEW_PROBLEM_HIDDEN) - udoc, dudoc = await asyncio.gather(user.get_by_uid(pdoc['owner_uid']), - domain.get_user(self.domainId, pdoc['owner_uid'])) - path_components = self.build_path( - (self.translate('problem_main'), self.reverse_url('problem_main')), - (pdoc['title'], self.reverse_url('problem_detail', pid=pdoc['_id'])), - (self.translate('problem_statistics'), None)) - self.render('problem_statistics.html', pdoc=pdoc, udoc=udoc, dudoc=dudoc, - page_title=pdoc['title'], path_components=path_components) - -*/ +Connection('/p/:pid/pretest-conn', ProblemPretestConnectionHandler); diff --git a/hydro/handler/record.js b/hydro/handler/record.js index 3beb64d37..1c6598974 100644 --- a/hydro/handler/record.js +++ b/hydro/handler/record.js @@ -96,6 +96,7 @@ class RecordConnectionHandler extends ConnectionHandler { async onRecordChange(data) { const rdoc = data.value; if (rdoc.tid && rdoc.tid.toString() !== this.tid) return; + // eslint-disable-next-line prefer-const let [udoc, pdoc] = await Promise.all([user.getById(rdoc.uid), problem.getById(rdoc.pid)]); if (pdoc.hidden && !this.user.hasPerm(PERM_VIEW_PROBLEM_HIDDEN)) pdoc = null; this.send({ html: await this.renderHTML('record_main_tr.html', { rdoc, udoc, pdoc }) }); diff --git a/hydro/handler/user.js b/hydro/handler/user.js index e27fa6aa5..d468075e0 100644 --- a/hydro/handler/user.js +++ b/hydro/handler/user.js @@ -91,7 +91,7 @@ class UserRegisterWithCodeHandler extends Handler { const { mail } = await token.get(code, token.TYPE_REGISTRATION); if (!mail) throw new InvalidTokenError(token.TYPE_REGISTRATION, code); if (password !== verifyPassword) throw new VerifyPasswordError(); - const uid = await system.incUserCounter(); + const uid = await system.inc('user'); await user.create({ uid, uname, password, mail, regip: this.request.ip, }); diff --git a/hydro/lib/i18n.js b/hydro/lib/i18n.js index 28fc4e589..acedbcb80 100644 --- a/hydro/lib/i18n.js +++ b/hydro/lib/i18n.js @@ -1,7 +1,4 @@ -const fs = require('fs'); -const yaml = require('js-yaml'); - -const locales = {}; +const locales = require('../../.build/locales.json'); String.prototype.format = function formatStr(...args) { let result = this; @@ -33,8 +30,4 @@ String.prototype.translate = function translate(language = 'zh_CN') { return this; }; -module.exports = function load(file, language) { - if (!locales[language]) locales[language] = {}; - const content = fs.readFileSync(file).toString(); - Object.assign(locales[language], yaml.safeLoad(content)); -}; +module.exports = locales; diff --git a/hydro/lib/md5.js b/hydro/lib/md5.js index 3d1e19dfc..59cdae888 100644 --- a/hydro/lib/md5.js +++ b/hydro/lib/md5.js @@ -1,4 +1,4 @@ -/* eslint-disable no-bitwise */ +/* eslint-disable no-bitwise */ /* eslint-disable max-len */ function safeAdd(x, y) { const lsw = (x & 0xffff) + (y & 0xffff); const msw = (x >> 16) + (y >> 16) + (lsw >> 16); diff --git a/hydro/model/discussion.js b/hydro/model/discussion.js new file mode 100644 index 000000000..a6acf1296 --- /dev/null +++ b/hydro/model/discussion.js @@ -0,0 +1,149 @@ +const { ObjectID } = require('bson'); +const { DocumentNotFoundError } = require('../error'); +const db = require('../service/db.js'); + +const coll = db.collection('discussion'); +const collReply = db.collection('discussion.reply'); +const collStatus = db.collection('discussion.status'); + +function add(parentType, parentId, owner, title, content, ip = null, highlight = false) { + return coll.insertOne({ + owner, + title, + content, + ip, + highlight, + parentType, + parentId, + nReply: 0, + updateAt: new Date(), + }); +} + +function get(did) { + return coll.findOne({ _id: did }); +} + +function edit(did, title, content, highlight) { + return coll.findOneAndUpdate({ _id: did }, { $set: { title, content, highlight } }); +} + +function del(did) { + return Promise.all([ + coll.deleteOne({ _id: did }), + collReply.deleteMany({ did }), + collStatus.deleteMany({ did }), + ]); +} + +function count(query) { + return coll.find(query).count(); +} + +function getMulti(query) { + return coll.find(query).sort([{ updateAt: -1 }, { _id: -1 }]); +} + +async function addReply(did, owner, content, ip) { + const [drdoc] = await Promise.all([ + collReply.insertOne({ + owner, did, content, ip, + }), + coll.updateOne({ _id: did }, { $inc: { nReply: 1 }, $set: { updateAt: new Date() } }), + ]); + return drdoc; +} + +function getReply(drid) { + return collReply.findOne({ _id: drid }); +} + +function editReply(drid, content) { + return collReply.updateOne({ _id: drid }, { $set: { content } }); +} + +async function delReply(drid) { + const drdoc = await getReply(drid); + if (!drdoc) throw new DocumentNotFoundError(drid); + return await Promise.all([ + collReply.deleteOne({ _id: drid }), + coll.updateOne({ _id: drdoc.did }, { $inc: { nReply: -1 } }), + ]); +} + +function getMultiReply(did) { + return coll.find({ did }).sort('_id', -1); +} + +function getListReply(did) { + return getMultiReply({ did }).toArray(); +} + +async function addTailReply(drid, owner, content, ip) { + let drdoc = await collReply.findOne({ _id: drid }); + const sid = new ObjectID(); + await Promise.all([ + collReply.updateOne({ _id: drid }, { + $push: { + reply: { + _id: sid, content, owner, ip, + }, + }, + }), + coll.updateOne({ _id: drdoc.did }, { $set: { updateAt: new Date() } }), + ]); + drdoc = await collReply.findOne({ _id: drid }); + return [drdoc, sid]; +} + +async function getTailReply(drid, drrid) { + const drdoc = await collReply.findOne({ _id: drid, reply: { $elemMatch: { _id: drrid } } }); + if (!drdoc) return [null, null]; + for (const drrdoc of drdoc) if (drrdoc._id === drrid) return [drdoc, drrdoc]; + return [drdoc, null]; +} + +async function editTailReply(drid, drrid, content) { + const drdoc = await collReply.findOne({ _id: drid, reply: { $elemMatch: { _id: drrid } } }); + const { reply } = drdoc; + for (const i in reply) { + if (reply[i]._id === drrid) { + reply[i].content = content; + break; + } + } + return await collReply.findOneAndUpdate({ _id: drdoc._id }, { $set: { reply } }); +} + +function delTailReply(drid, drrid) { + return coll.findOneAndUpdate({ _id: drid }, { $pull: { reply: { _id: drrid } } }); +} + +function setStar(did, uid, star) { + return collStatus.findOneAndUpdate({ did, uid }, { $set: { star } }, { upsert: true }); +} + +function getStatus(did, uid) { + return collStatus.findOne({ did, uid }); +} + +module.exports = { + add, + get, + edit, + del, + count, + getMulti, + addReply, + getReply, + editReply, + delReply, + getMultiReply, + getListReply, + addTailReply, + getTailReply, + editTailReply, + delTailReply, + setStar, + getStatus, +}; diff --git a/hydro/model/solution.js b/hydro/model/solution.js index 43b8a8d1d..ca8c1e8cd 100644 --- a/hydro/model/solution.js +++ b/hydro/model/solution.js @@ -19,16 +19,19 @@ async function add(pid, owner, content) { }); return res.insertedId; } + async function get(psid) { psid = new ObjectID(psid); const psdoc = await coll.findOne({ _id: psid }); if (!psdoc) throw new SolutionNotFoundError(); return psdoc; } + function getMany(query, sort, page, limit) { return coll.find(query).sort(sort).skip((page - 1) * limit).limit(limit) .toArray(); } + async function edit(_id, content) { validator.checkContent(content); await coll.findOneAndUpdate({ _id }, { $set: { content } }); @@ -36,15 +39,19 @@ async function edit(_id, content) { if (!psdoc) throw new SolutionNotFoundError(_id); return psdoc; } + function del(psid) { return coll.deleteOne({ _id: psid }); } + function count(query) { return coll.find(query).count(); } + function getMulti(pid) { return coll.find({ pid }).sort({ vote: -1 }); } + function reply(psid, owner, content) { validator.checkContent(content); return coll.findOneAndUpdate( @@ -52,18 +59,20 @@ function reply(psid, owner, content) { { $push: { reply: { content, owner, _id: new ObjectID() } } }, ); } + async function getReply(psid, psrid) { const psdoc = await coll.findOne({ _id: psid, reply: { $elemMatch: { _id: psrid } } }); if (!psdoc) return [null, null]; for (const psrdoc of psdoc) if (psrdoc._id === psrid) return [psdoc, psrdoc]; return [psdoc, null]; } + async function editReply(psid, psrid, content) { validator.checkContent(content); psid = new ObjectID(psid); psrid = new ObjectID(psrid); const psdoc = await coll.findOne({ _id: psid, reply: { $elemMatch: { _id: psrid } } }); - const { reply } = psdoc; + const { reply } = psdoc; // eslint-disable-line no-shadow for (const i in reply) { if (reply[i]._id === psrid) { reply[i].content = content; @@ -72,9 +81,11 @@ async function editReply(psid, psrid, content) { } return await coll.findOneAndUpdate({ _id: psdoc._id }, { $set: { reply } }); } + function delReply(psid, psrid) { return coll.findOneAndUpdate({ _id: psid }, { $pull: { reply: { _id: psrid } } }); } + async function vote(psid, uid, value) { let pssdoc = await collStatus.findOne({ psid, uid }); if (pssdoc) await collStatus.deleteOne({ psid, uid }); @@ -85,12 +96,14 @@ async function vote(psid, uid, value) { const psdoc = await coll.findOne({ _id: psid }); return [psdoc, pssdoc]; } + async function getListStatus(list, uid) { const result = {}; const res = await collStatus.find({ uid, psid: { $in: list } }).toArray(); for (const i of res) result[i.psid] = i; return result; } + module.exports = { count, add, diff --git a/hydro/model/system.js b/hydro/model/system.js index 015851615..da674bccb 100644 --- a/hydro/model/system.js +++ b/hydro/model/system.js @@ -11,41 +11,20 @@ async function update(_id, operation, config) { await coll.findOneAndUpdate({ _id }, operation, config); return get(_id); } +async function set(_id, value) { + await coll.findOneAndUpdate({ _id }, { value }, { upsert: true }); + return get(_id); +} /** - * Increments the user counter. + * Increments the counter. * @returns {number} Integer value after increment. */ -function incUserCounter() { - return update('userCounter', { $inc: { value: 1 } }, { upsert: true }); -} -/** - * Increments the problem ID counter. - * @returns {number} Integer value before increment. - */ -function incPidCounter() { - return update('userCounter', { $inc: { value: 1 } }, { upsert: true }); -} -async function acquireLock(name) { - const value = Math.floor(Math.random() * 0xFFFFFFFF); - try { - await coll.updateOne({ _id: `lock_${name}`, value: 0 }, { $set: { value } }, { upsert: true }); - } catch (e) { return null; } - return value; -} -async function releaseLock(name, value) { - const result = await coll.updateOne({ _id: `lock_${name}`, value }, { $set: { value: 0 } }); - return !!result.matchedCount; -} -async function releaseLockAnyway(name) { - await coll.updateOne({ _id: `lock_${name}` }, { $set: { value: 0 } }); - return true; +function inc(field) { + return update(field, { $inc: { value: 1 } }, { upsert: true }); } module.exports = { get, update, - incPidCounter, - incUserCounter, - acquireLock, - releaseLock, - releaseLockAnyway, + inc, + set, }; diff --git a/hydro/model/training.js b/hydro/model/training.js index 7d20936f7..aee594c16 100644 --- a/hydro/model/training.js +++ b/hydro/model/training.js @@ -9,15 +9,18 @@ const collStatus = db.collection('training.status'); function getStatus(tid, uid) { return collStatus.findOne({ tid, uid }); } + function getMultiStatus(query) { return collStatus.find(query); } + async function getListStatus(uid, tids) { const tsdocs = await getMultiStatus({ uid, tid: { $in: Array.from(new Set(tids)) } }).toArray(); const r = {}; for (const tsdoc of tsdocs) r[tsdoc.pid] = tsdoc; return r; } + async function enroll(tid, uid) { try { await collStatus.insertOne({ tid, uid, enroll: 1 }); @@ -26,6 +29,7 @@ async function enroll(tid, uid) { } await coll.findOneAndUpdate({ _id: tid }, { $inc: { enroll: 1 } }); } + async function setStatus(tid, uid, $set) { await collStatus.findOneAndUpdate({ tid, uid }, { $set }); return await collStatus.findOne({ tid, uid }); diff --git a/hydro/model/user.js b/hydro/model/user.js index ad1a8bac1..fa61dc4d1 100644 --- a/hydro/model/user.js +++ b/hydro/model/user.js @@ -83,7 +83,7 @@ async function changePassword(uid, currentPassword, newPassword) { const udoc = await getById(uid); udoc.checkPassword(currentPassword); const salt = pwhash.salt(); - return await coll.findOneAndUpdate({ + return await coll.findOneAndUpdate({ // eslint-disable-line no-return-await _id: udoc._id, }, { $set: { salt, hash: pwhash.hash(newPassword, salt) }, @@ -101,7 +101,7 @@ async function create({ validator.checkPassword(password); validator.checkEmail(mail); const salt = pwhash.salt(); - if (!uid) uid = system.incUserCounter(); + if (!uid) uid = system.inc('user'); try { await coll.insertOne({ _id: uid, @@ -127,6 +127,10 @@ function getMany(params) { return coll.find(params); } +function getRoles() { + return collRole.find().toArray(); +} + module.exports = { changePassword, create, @@ -139,4 +143,5 @@ module.exports = { setEmail, setPassword, getList, + getRoles, }; diff --git a/hydro/options.js b/hydro/options.js index 2149856d3..dfd6d41e4 100644 --- a/hydro/options.js +++ b/hydro/options.js @@ -58,4 +58,5 @@ try { } catch (e) { console.error('Cannot load config'); } + module.exports = options; diff --git a/hydro/service/db.js b/hydro/service/db.js index a0a278759..e82fe41f4 100644 --- a/hydro/service/db.js +++ b/hydro/service/db.js @@ -1,4 +1,5 @@ const Mongo = require('mongodb'); +const bus = require('../service/bus'); const options = require('../options'); let mongourl = 'mongodb://'; @@ -12,7 +13,7 @@ Mongo.MongoClient.connect(mongourl, { useNewUrlParser: true, useUnifiedTopology: .then((Database) => { db = Database.db(options.db.name); db.Db = Database; - global.bus.emit('connected'); + bus.publish('system_database_connected', null); }); module.exports = { diff --git a/hydro/service/server.js b/hydro/service/server.js index 0566f454b..817a075c9 100644 --- a/hydro/service/server.js +++ b/hydro/service/server.js @@ -17,7 +17,7 @@ const token = require('../model/token'); const opcount = require('../model/opcount'); const { UserNotFoundError, BlacklistedError, PermissionError, - NotFoundError, UserFacingError, + NotFoundError, UserFacingError, ValidationError, } = require('../error'); const app = new Koa(); @@ -111,7 +111,8 @@ class Handler { await opcount.inc(op, this.request.ip, periodSecs, maxOperations); } - back() { + back(body) { + if (body) this.response.body = body; this.response.redirect = this.request.headers.referer || '/'; } @@ -236,6 +237,9 @@ class Handler { await this.___cleanup().catch(() => { }); } } + +const check = ['tid', 'rid', 'did', 'drid', 'drrid', 'psid', 'psrid']; + function Route(route, RouteHandler) { router.all(route, async (ctx) => { const h = new RouteHandler(ctx); @@ -243,12 +247,16 @@ function Route(route, RouteHandler) { const method = ctx.method.toLowerCase(); const args = { ...ctx.params, ...ctx.query, ...ctx.request.body }; + for (const l of check) { + if (args[l]) { + args[l] = new ObjectID(args[l]); + if (!args[l]) throw new ValidationError(l); + } + } if (args.content) validator.checkContent(args.content); if (args.title) validator.checkContent(args.title); if (args.uid) args.uid = parseInt(validator.checkUid(args.uid)); if (args.page) args.page = parseInt(args.page); - if (args.rid) args.rid = new ObjectID(args.rid); - if (args.tid) args.tid = new ObjectID(args.tid); if (args.duration) args.duration = parseFloat(args.duration); if (args.pids) args.pids = args.pids.split(',').map((i) => i.trim()); diff --git a/package.json b/package.json index 9aa8fd9a3..3214b6d97 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,9 @@ "eslint-plugin-import": "^2.20.2" }, "scripts": { - "build": "webpack --config webpack.config.js", + "build": "node build/index.js", "pack": "pkg .", - "lint": "eslint judger --fix" + "lint": "eslint hydro --fix" }, "pkg": { "scripts": [ @@ -46,4 +46,4 @@ "bin": { "judger": "hydro/development.js" } -} +} \ No newline at end of file diff --git a/templates/discussion_create.html b/templates/discussion_create.html index ead58799f..7d5994345 100644 --- a/templates/discussion_create.html +++ b/templates/discussion_create.html @@ -4,7 +4,7 @@
-

{{ _('Create Discussion in {0}').format(vnode['title']) }}

+

{{ _('Create Discussion in {0}').format(title) }}

diff --git a/templates/domain_manage_join_applications.html b/templates/domain_manage_join_applications.html deleted file mode 100644 index 0ddd9c02b..000000000 --- a/templates/domain_manage_join_applications.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "domain_base.html" %} -{% block domain_content %} -{% if join_settings != None %} -
-
-

{{ _('Information') }}

-
-
-

{{ _('User can join this domain by visiting the following URL') }}:

-
{{ handler.ui_context['url_prefix'] }}{{ reverse_url('domain_join') }}
- {% if join_settings['method'] == vj4.constant.domain.JOIN_METHOD_CODE %} -

{{ _('Or, with automatically filled invitation code') }}:

-
{{ handler.ui_context['url_prefix'] }}{{ reverse_url('domain_join') }}?code={{ join_settings['code']|urlencode }}
- {% endif %} - {% if join_settings['expire'] != None %} -
-
- {{ (_('The link will be expired at {0}').format(datetime_span(join_settings['expire'], relative=false)))|safe }} -
- {% endif %} -
-
-{% endif %} -
-
-

{{ _('Settings') }}

-
-
- - {{ form.form_radio( - options=vj4.constant.domain.JOIN_METHOD_RANGE.items(), - label='Method', - name='method', - value=join_settings['method']|default(vj4.constant.domain.JOIN_METHOD_NONE), - columns=8) }} - {{ form.form_select( - options=roles_with_text, - label='Role Assignment', - name='role', - help_text='The role to assign when user joining the domain.', - value=join_settings['role']|default('')) }} - {{ form.form_select( - options=expirations.items(), - label='Expire', - name='expire', - help_text='User will no longer be allowed to join the domain after expiration.' - ) }} - {{ form.form_text( - label='Invitation Code', - name='invitation_code', - help_text='The invitation code to enter to successfully join the domain. You can only use letters and numbers in the code and it should not be longer than 64 characters.', - value=join_settings['code']|default('')) }} - - - -
-
-{% endblock %} diff --git a/templates/domain_manage_permission.html b/templates/domain_manage_permission.html index 0926aa8a9..f8a8868cc 100644 --- a/templates/domain_manage_permission.html +++ b/templates/domain_manage_permission.html @@ -36,7 +36,7 @@ {% for role in roles %} {% endfor %} diff --git a/templates/domain_manage_role.html b/templates/domain_manage_role.html index 53001c74a..975b7c131 100644 --- a/templates/domain_manage_role.html +++ b/templates/domain_manage_role.html @@ -9,7 +9,12 @@

{{ _('Role name can only contains numbers, letters and underscores.') }}

- {{ form.form_text(columns=None, label='Name', name='role', autofocus=True) }} + {{ form.form_text({ + columns:None, + label:'Name', + name:'role', + autofocus:true + }) }}
diff --git a/templates/partials/discussion_edit_form.html b/templates/partials/discussion_edit_form.html index e6da0b595..5b85fb292 100644 --- a/templates/partials/discussion_edit_form.html +++ b/templates/partials/discussion_edit_form.html @@ -1,6 +1,24 @@ {% import "components/form.html" as form with context %} -{% if ddoc['highlight'] or handler.hasPerm(perm.PERM_HIGHLIGHT_DISCUSSION) %} - {{ form.form_checkbox(label='Highlight', name='highlight', value=ddoc['highlight']|default(false)) }} +{% if ddoc.highlight or handler.hasPerm(perm.PERM_HIGHLIGHT_DISCUSSION) %} + {{ form.form_checkbox({ + label:'Highlight', + name:'highlight', + value:ddoc.highlight|default(false)) + }) }} {% endif %} - {{ form.form_text(label='Title', name='title', value=ddoc['title']|default(''), autofocus=true, required=true) }} - {{ form.form_textarea(columns=12, label='Content', name='content', value=ddoc['content']|default(''), hotkeys='ctrl+enter:submit', markdown=true, required=true) }} + {{ form.form_text({ + label:'Title', + name:'title', + value:ddoc.title|default(''), + autofocus:true, + required:true + }) }} + {{ form.form_textarea({ + columns:12, + label:'Content', + name:'content', + value:ddoc.content|default(''), + hotkeys:'ctrl+enter:submit', + markdown:true, + required:true + }) }} diff --git a/templates/partials/discussion_list.html b/templates/partials/discussion_list.html index ec9d5e0a6..bfc648f72 100644 --- a/templates/partials/discussion_list.html +++ b/templates/partials/discussion_list.html @@ -1,46 +1,46 @@ {% import "components/nothing.html" as nothing with context %} {% import "components/user.html" as user with context %} {% import "components/paginator.html" as paginator with context %} - {% if not ddocs %} - {{ nothing.render('no discussion yet...') }} - {% else %} -
    - {% for ddoc in ddocs %} -
  1. -
    -
    -
    -
    {{ ddoc['num_replies'] }}
    -
    {{ _('Comments') }}
    -
    -
    - +{% if not ddocs.length %} + {{ nothing.render('no discussion yet...') }} +{% else %} +
      + {% for ddoc in ddocs %} +
    1. +
      +
      +
      +
      {{ ddoc.nReply }}
      +
      {{ _('Comments') }}
      -
    2. - {% endfor %} -
    - {% if page != undefined and dpcount != undefined %} - {{ paginator.render(page, dpcount) }} - {% endif %} - {% endif %} +
    + + +
  2. + {% endfor %} +
+ {% if page != undefined and dpcount != undefined %} + {{ paginator.render(page, dpcount) }} + {% endif %} +{% endif %} diff --git a/templates/partials/problem_lucky.html b/templates/partials/problem_lucky.html index 51224a9d1..04cfd7ee1 100644 --- a/templates/partials/problem_lucky.html +++ b/templates/partials/problem_lucky.html @@ -1,10 +1,10 @@
-

- - {{ _('Lucky') }} - -

-
-

{{ _('Pick a problem randomly based on current filter') }}

-
+

+ + {{ _('Lucky') }} + +

+
+

{{ _('Pick a problem randomly based on current filter') }}

+
diff --git a/templates/partials/problem_stat.html b/templates/partials/problem_stat.html index 6ea2ba065..56604e18d 100644 --- a/templates/partials/problem_stat.html +++ b/templates/partials/problem_stat.html @@ -1,3 +1,3 @@
-

{{ _('{0} problems').format(pcount) }}

+

{{ _('{0} problems').format(pcount) }}

\ No newline at end of file diff --git a/templates/partials/training_default.json b/templates/partials/training_default.json index b86ec296a..f39debcae 100644 --- a/templates/partials/training_default.json +++ b/templates/partials/training_default.json @@ -2,13 +2,13 @@ { "_id": 1, "title": "最初的最初 - A+B Problem", - "require_nids": [], + "requireNids": [], "pids": [1000, "56c52ade2fefd90539000005"] }, { "_id": 2, "title": "最初的进阶", - "require_nids": [1], + "requireNids": [1], "pids": [1001, 1002] } ] diff --git a/templates/wiki_help.html b/templates/wiki_help.html index cbff48209..89d028f65 100644 --- a/templates/wiki_help.html +++ b/templates/wiki_help.html @@ -8,17 +8,17 @@ {{ _('no_translation_warn')|safe }}

每个用户可以创建自己的域。比如,老师可以为课程创建一个域,然后把题目和学生加入到此域中。再如,可以把域作为团队功能。

通过合理的设置角色及其权限,可以把域设为公开或私有。

-

默认域为Vijos,用户直接访问Vijos域名就会访问此域,点击Vijos Logo会从任意域跳转回Vijos域。

+

默认域为Hydro,用户直接访问Hydro域名就会访问此域,点击Hydro Logo会从任意域跳转回Hydro域。

每个域的题库、讨论、训练以及比赛是独立且自治的。

-

{{ _('Compilers\' Version and Parameters') }}

+

{{ _("Compilers' Version and Parameters") }}

{{ _('no_translation_warn')|safe }} -

Vijos使用jd4进行评测,以docker镜像的形式安装至评测机。所使用的编译器均为创建镜像时debian testing分支的最新版。编译参数如下:

+

Hydro使用HydroJudger进行评测,以docker镜像的形式安装至评测机。所使用的编译器均为创建镜像时debian testing分支的最新版。编译参数如下:

  • Free Pascal
    fpc -O2 -o/out/foo /in/foo.pas
  • C
    gcc -O2 -Wall -std=c99 -o /out/foo /in/foo.c -lm
  • @@ -38,7 +38,8 @@
{{ _('no_translation_warn')|safe }} -

Vijos 评测机使用进程的CPU时间计算时间消耗,时间的限定为题目中评测点所指定的时间。

Vijos 评测机使用进程虚拟内存与物理内存总和计算内存空间消耗。内存空间默认限定为256MiB,题目中特别指明的,限定为题目中评测点所指定的内存空间。

+

Hydro 评测机使用进程的CPU时间计算时间消耗,时间的限定为题目中评测点所指定的时间。

+

Hydro 评测机使用进程虚拟内存与物理内存总和计算内存空间消耗。内存空间默认限定为256MiB,题目中特别指明的,限定为题目中评测点所指定的内存空间。

@@ -47,7 +48,7 @@
{{ _('no_translation_warn')|safe }} -

Vijos 支持且仅支持标准输入输出(控制台输入输出,屏幕输入输出,STD I/O),不支持文件输入输出。

+

若无特殊说明,Hydro 使用标准输入输出(控制台输入输出,屏幕输入输出,STD I/O)。

@@ -130,7 +131,7 @@
{{ _('no_translation_warn')|safe }} -

通过率的影响极其恶劣,现在 Vijos 不提供也不承认通过率。

+

通过率的影响极其恶劣,现在 Hydro 不提供也不承认通过率。

@@ -139,7 +140,7 @@
{{ _('no_translation_warn')|safe }} -

Vijos RP 分为固定 RP 与浮动 RP ,固定 RP 可由活动、比赛等的奖励获得,浮动 RP 由一般性通过题目获得。

+

Hydro RP 分为固定 RP 与浮动 RP ,固定 RP 可由活动、比赛等的奖励获得,浮动 RP 由一般性通过题目获得。

浮动 RP 是一个动态的数值,每个用户在每道题上获得的 RP 都不一样,并且未来也不固定(即浮动之意)。

每道题的浮动 RP 由该题通过总人数与该用户通过此题的名次计算得来。
@@ -150,7 +151,7 @@
{{ _('no_translation_warn')|safe }} -

Vijos 中题目的难度,根据递交数、通过率以及每个递交的递交时间和评测结果,通过算法计算得出。

+

Hydro 中题目的难度,根据递交数、通过率以及每个递交的递交时间和评测结果,通过算法计算得出。

因此,请注意以下几点: