diff --git a/.gitignore b/.gitignore index e4d53e4..c89a998 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ docker-compose.local.yml .config.php .config.development.php .config.local.php +*.development.env +*.local.env diff --git a/docker-compose.development.yml b/docker-compose.development.yml index f0939dd..a7bc2fa 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -52,9 +52,14 @@ services: restart: always volumes: - ./uoj_data/judger/log:/opt/uoj_judger/log + env_file: + - remote-judger.development.env environment: + - DEV=true - UOJ_PROTOCOL=http - UOJ_HOST=uoj-web + - UOJ_JUDGER_NAME=remote_judger + - UOJ_JUDGER_PASSWORD=_judger_password_ uoj-web: build: diff --git a/remote_judger/Dockerfile b/remote_judger/Dockerfile index 0c66772..c43850d 100644 --- a/remote_judger/Dockerfile +++ b/remote_judger/Dockerfile @@ -9,4 +9,4 @@ COPY . . RUN npm run build -CMD [ "node", "dist/entrypoint.js" ] +CMD [ "node", "--experimental-specifier-resolution=node", "dist/entrypoint.js" ] diff --git a/remote_judger/README b/remote_judger/README new file mode 100644 index 0000000..ef3ed2b --- /dev/null +++ b/remote_judger/README @@ -0,0 +1,5 @@ +本模块借鉴了以下项目的源码: + +- https://github.com/hydro-dev/Hydro/blob/feb51804766e35dbd13f7cb74fda95c0b783c49d/packages/vjudge/ + +在此表示感谢。 diff --git a/remote_judger/add_judger.sql b/remote_judger/add_judger.sql new file mode 100644 index 0000000..dda84f5 --- /dev/null +++ b/remote_judger/add_judger.sql @@ -0,0 +1,2 @@ +USE `app_uoj233`; +insert into judger_info (judger_name, password, ip) values ('remote_judger', '_judger_password_', 'uoj-remote-judger'); diff --git a/remote_judger/package-lock.json b/remote_judger/package-lock.json index 3f5c0b4..0e1e96d 100644 --- a/remote_judger/package-lock.json +++ b/remote_judger/package-lock.json @@ -9,15 +9,15 @@ "version": "0.0.0", "license": "AGPL-3.0", "dependencies": { + "fs-extra": "^11.1.0", "jsdom": "^21.0.0", "math-sum": "^2.0.0", "reggol": "^1.3.4", "superagent": "^8.0.6", - "superagent-prefix": "^0.0.2", - "superagent-proxy": "^3.0.0", - "xml-js": "^1.6.11" + "superagent-proxy": "^3.0.0" }, "devDependencies": { + "@types/fs-extra": "^11.0.1", "@types/js-yaml": "^4.0.5", "@types/jsdom": "^20.0.1", "@types/node": "^18.11.18", @@ -41,6 +41,16 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/fs-extra": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.1.tgz", + "integrity": "sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==", + "dev": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -58,6 +68,15 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/jsonfile": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.1.tgz", + "integrity": "sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -464,16 +483,16 @@ } }, "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", + "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", "dependencies": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=14.14" } }, "node_modules/ftp": { @@ -530,6 +549,35 @@ "node": ">= 6" } }, + "node_modules/get-uri/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-uri/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/get-uri/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -712,9 +760,12 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -1047,11 +1098,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -1177,11 +1223,6 @@ "node": ">=6.4.0 <13 || >=14" } }, - "node_modules/superagent-prefix": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/superagent-prefix/-/superagent-prefix-0.0.2.tgz", - "integrity": "sha512-LssjLYPklgE/ZlaowG+DZjSvXkiwyT47U3D1tvgDlAeG7w7ew5MowVXwepIt+yoVql2xQmgR/sJr2sivkRyN7g==" - }, "node_modules/superagent-proxy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/superagent-proxy/-/superagent-proxy-3.0.0.tgz", @@ -1286,11 +1327,11 @@ } }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unpipe": { @@ -1419,17 +1460,6 @@ } } }, - "node_modules/xml-js": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", - "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "dependencies": { - "sax": "^1.2.4" - }, - "bin": { - "xml-js": "bin/cli.js" - } - }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -1469,6 +1499,16 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "@types/fs-extra": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.1.tgz", + "integrity": "sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==", + "dev": true, + "requires": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -1486,6 +1526,15 @@ "parse5": "^7.0.0" } }, + "@types/jsonfile": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.1.tgz", + "integrity": "sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -1794,13 +1843,13 @@ } }, "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", + "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", "requires": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" } }, "ftp": { @@ -1844,6 +1893,29 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" } } }, @@ -1985,11 +2057,12 @@ } }, "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "requires": { - "graceful-fs": "^4.1.6" + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" } }, "levn": { @@ -2249,11 +2322,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, "saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2349,11 +2417,6 @@ "semver": "^7.3.8" } }, - "superagent-prefix": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/superagent-prefix/-/superagent-prefix-0.0.2.tgz", - "integrity": "sha512-LssjLYPklgE/ZlaowG+DZjSvXkiwyT47U3D1tvgDlAeG7w7ew5MowVXwepIt+yoVql2xQmgR/sJr2sivkRyN7g==" - }, "superagent-proxy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/superagent-proxy/-/superagent-proxy-3.0.0.tgz", @@ -2425,9 +2488,9 @@ "dev": true }, "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" }, "unpipe": { "version": "1.0.0", @@ -2513,14 +2576,6 @@ "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", "requires": {} }, - "xml-js": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", - "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "requires": { - "sax": "^1.2.4" - } - }, "xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/remote_judger/package.json b/remote_judger/package.json index d47a05a..efde41f 100644 --- a/remote_judger/package.json +++ b/remote_judger/package.json @@ -12,15 +12,15 @@ "license": "AGPL-3.0", "private": true, "dependencies": { + "fs-extra": "^11.1.0", "jsdom": "^21.0.0", "math-sum": "^2.0.0", "reggol": "^1.3.4", "superagent": "^8.0.6", - "superagent-prefix": "^0.0.2", - "superagent-proxy": "^3.0.0", - "xml-js": "^1.6.11" + "superagent-proxy": "^3.0.0" }, "devDependencies": { + "@types/fs-extra": "^11.0.1", "@types/js-yaml": "^4.0.5", "@types/jsdom": "^20.0.1", "@types/node": "^18.11.18", diff --git a/remote_judger/src/daemon.ts b/remote_judger/src/daemon.ts index d53ebe7..2d31d11 100644 --- a/remote_judger/src/daemon.ts +++ b/remote_judger/src/daemon.ts @@ -1,10 +1,13 @@ -import fs from 'fs'; +import fs from 'fs-extra'; import superagent from 'superagent'; import proxy from 'superagent-proxy'; -import prefix from 'superagent-prefix'; -import Logger from '@/utils/logger'; -import sleep from '@/utils/sleep'; -import * as TIME from '@/utils/time'; +import Logger from './utils/logger'; +import sleep from './utils/sleep'; +import * as TIME from './utils/time'; +import htmlspecialchars from './utils/htmlspecialchars'; +import { apply } from './vjudge'; +import path from 'path'; +import child from 'child_process'; proxy(superagent); @@ -22,41 +25,152 @@ interface UOJSubmission { problem_mtime: number; content: any; status: string; + judge_time: string; } export default async function daemon(config: UOJConfig) { - const agent = superagent - .agent() - .use(prefix(`${this.config.server_url}/judge`)) - .type('application/x-www-form-urlencoded') - .serialize(data => - new URLSearchParams({ - judger_name: config.judger_name, - password: config.password, - ...data, - }).toString() - ); + const request = (url: string, data = {}) => + superagent + .post(`${config.server_url}/judge${url}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send( + Object.entries({ + judger_name: config.judger_name, + password: config.password, + ...data, + }) + .map( + ([k, v]) => + `${k}=${encodeURIComponent( + typeof v === 'string' ? v : JSON.stringify(v) + )}` + ) + .join('&') + ); + const vjudge = await apply(request); while (true) { try { - const { body, error } = await agent.post('/submit'); + const { text, error } = await request('/submit'); if (error) { - logger.error(error.message); + logger.error('/submit', error.message); - await sleep(TIME.second); - } else if (body === 'Nothing to judge') { - await sleep(2 * TIME.second); + await sleep(3 * TIME.second); + } else if (text.startsWith('Nothing to judge')) { + await sleep(3 * TIME.second); } else { - const data: UOJSubmission = JSON.parse(body); + const data: UOJSubmission = JSON.parse(text); + const { id, content, judge_time } = data; + const config = Object.fromEntries(content.config); + const tmpdir = `/tmp/s2oj_rmj/${id}/`; - logger.info('Start judging', data.id); + fs.ensureDirSync(tmpdir); - // TODO: judge + const reportError = async (error: string, details: string) => { + await request('/submit', { + submit: true, + fetch_new: false, + id, + result: JSON.stringify({ + status: 'Judged', + score: 0, + error, + details: `${htmlspecialchars(details)}`, + }), + judge_time, + }); + }; + + // Download source code + logger.debug('Downloading source code.', id); + const zipFilePath = path.resolve(tmpdir, 'all.zip'); + const res = request(`/download${content.file_name}`); + const stream = fs.createWriteStream(zipFilePath); + res.pipe(stream); + + try { + await new Promise((resolve, reject) => { + stream.on('finish', resolve); + stream.on('error', reject); + }); + } catch (e) { + await reportError( + 'Judgment Failed', + `Failed to download source code.` + ); + logger.error('Failed to download source code.', id, e.message); + + fs.removeSync(tmpdir); + + continue; + } + + // Unzip source code + logger.debug('Unzipping source code.', id); + const extractedPath = path.resolve(tmpdir, 'all'); + + try { + await new Promise((resolve, reject) => { + child.exec(`unzip ${zipFilePath} -d ${extractedPath}`, e => { + if (e) reject(e); + else resolve(true); + }); + }); + } catch (e) { + await reportError('Judgment Failed', `Failed to unzip source code.`); + logger.error('Failed to unzip source code.', id, e.message); + + fs.removeSync(tmpdir); + + continue; + } + + // Read source code + logger.debug('Reading source code.', id); + const sourceCodePath = path.resolve(extractedPath, 'answer.code'); + let code = ''; + + try { + code = fs.readFileSync(sourceCodePath, 'utf-8'); + } catch (e) { + await reportError('Judgment Failed', `Failed to read source code.`); + logger.error('Failed to read source code.', id, e.message); + + fs.removeSync(tmpdir); + + continue; + } + + // Start judging + logger.info('Start judging', id, `(problem ${data.problem_id})`); + try { + await vjudge.judge( + id, + config.remote_online_judge, + config.remote_problem_id, + config.answer_language, + code, + judge_time + ); + } catch (err) { + await reportError( + 'Judgment Failed', + 'No details, please contact admin!' + ); + logger.error('Judgment Failed.', id, err.message); + + fs.removeSync(tmpdir); + + continue; + } + + fs.removeSync(tmpdir); } } catch (err) { - logger.error(err); - await sleep(TIME.second); + logger.error(err.message); + + await sleep(3 * TIME.second); } } } diff --git a/remote_judger/src/interface.ts b/remote_judger/src/interface.ts new file mode 100644 index 0000000..edf1ddf --- /dev/null +++ b/remote_judger/src/interface.ts @@ -0,0 +1,31 @@ +export interface RemoteAccount { + type: string; + cookie?: string[]; + handle: string; + password: string; + endpoint?: string; + proxy?: string; +} + +export type NextFunction = (body: Partial) => void; + +export interface IBasicProvider { + ensureLogin(): Promise; + submitProblem( + id: string, + lang: string, + code: string, + submissionId: number, + next: NextFunction, + end: NextFunction + ): Promise; + waitForSubmission( + id: string, + next: NextFunction, + end: NextFunction + ): Promise; +} + +export interface BasicProvider { + new (account: RemoteAccount): IBasicProvider; +} diff --git a/remote_judger/src/providers/codeforces.ts b/remote_judger/src/providers/codeforces.ts new file mode 100644 index 0000000..68fa713 --- /dev/null +++ b/remote_judger/src/providers/codeforces.ts @@ -0,0 +1,325 @@ +import { JSDOM } from 'jsdom'; +import superagent from 'superagent'; +import proxy from 'superagent-proxy'; +import sleep from '../utils/sleep'; +import mathSum from 'math-sum'; +import { IBasicProvider, RemoteAccount } from '../interface'; +import { normalize, VERDICT } from '../verdict'; +import Logger from '../utils/logger'; + +proxy(superagent); +const logger = new Logger('remote/codeforces'); + +const langs_map = { + C: { + name: 'GNU GCC C11 5.1.0', + id: 43, + comment: '//', + }, + 'C++': { + name: 'GNU G++14 6.4.0', + id: 50, + comment: '//', + }, + 'C++17': { + name: 'GNU G++17 7.3.0', + id: 54, + comment: '//', + }, + Pascal: { + name: 'Free Pascal 3.0.2', + id: 4, + comment: '//', + }, + 'Python2.7': { + name: 'Python 2.7.18', + id: 7, + comment: '#', + }, + Python3: { + name: 'Python 3.9.1', + id: 31, + comment: '#', + }, +}; + +export function getAccountInfoFromEnv(): RemoteAccount | null { + const { + CODEFORCES_HANDLE, + CODEFORCES_PASSWORD, + CODEFORCES_ENDPOINT = 'https://codeforces.com', + CODEFORCES_PROXY, + } = process.env; + + if (!CODEFORCES_HANDLE || !CODEFORCES_PASSWORD) return null; + + const account: RemoteAccount = { + type: 'codeforces', + handle: CODEFORCES_HANDLE, + password: CODEFORCES_PASSWORD, + endpoint: CODEFORCES_ENDPOINT, + }; + + if (CODEFORCES_PROXY) account.proxy = CODEFORCES_PROXY; + + return account; +} + +function parseProblemId(id: string) { + const [, type, contestId, problemId] = id.startsWith('921') + ? ['', '921', '01'] + : /^(|GYM)(\d+)([A-Z]+[0-9]*)$/.exec(id); + if (type === 'GYM' && +contestId < 100000) { + return [type, (+contestId + 100000).toString(), problemId]; + } + return [type, contestId, problemId]; +} + +export default class CodeforcesProvider implements IBasicProvider { + constructor(public account: RemoteAccount) { + if (account.cookie) this.cookie = account.cookie; + this.account.endpoint ||= 'https://codeforces.com'; + } + + cookie: string[] = []; + csrf: string; + + get(url: string) { + logger.debug('get', url); + if (!url.includes('//')) url = `${this.account.endpoint}${url}`; + const req = superagent + .get(url) + .set('Cookie', this.cookie) + .set( + 'User-Agent', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36 S2OJ/3.1.0' + ); + if (this.account.proxy) return req.proxy(this.account.proxy); + return req; + } + + post(url: string) { + logger.debug('post', url, this.cookie); + if (!url.includes('//')) url = `${this.account.endpoint}${url}`; + const req = superagent + .post(url) + .type('form') + .set('Cookie', this.cookie) + .set( + 'User-Agent', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36 S2OJ/3.1.0' + ); + if (this.account.proxy) return req.proxy(this.account.proxy); + return req; + } + + getCookie(target: string) { + return this.cookie + .find(i => i.startsWith(`${target}=`)) + ?.split('=')[1] + ?.split(';')[0]; + } + + setCookie(target: string, value: string) { + this.cookie = this.cookie.filter(i => !i.startsWith(`${target}=`)); + this.cookie.push(`${target}=${value}`); + } + + tta(_39ce7: string) { + let _tta = 0; + for (let c = 0; c < _39ce7.length; c++) { + _tta = (_tta + (c + 1) * (c + 2) * _39ce7.charCodeAt(c)) % 1009; + if (c % 3 === 0) _tta++; + if (c % 2 === 0) _tta *= 2; + if (c > 0) + _tta -= + Math.floor(_39ce7.charCodeAt(Math.floor(c / 2)) / 2) * (_tta % 5); + _tta = ((_tta % 1009) + 1009) % 1009; + } + return _tta; + } + + async getCsrfToken(url: string) { + const { text: html } = await this.get(url); + const { + window: { document }, + } = new JSDOM(html); + if (document.body.children.length < 2 && html.length < 512) { + throw new Error(document.body.textContent!); + } + const ftaa = this.getCookie('70a7c28f3de') || 'n/a'; + const bfaa = this.getCookie('raa') || this.getCookie('bfaa') || 'n/a'; + return [ + ( + document.querySelector('meta[name="X-Csrf-Token"]') || + document.querySelector('input[name="csrf_token"]') + )?.getAttribute('content'), + ftaa, + bfaa, + ]; + } + + get loggedIn() { + return this.get('/enter').then(res => { + const html = res.text; + if (html.includes('Login into Codeforces')) return false; + if (html.length < 1000 && html.includes('Redirecting...')) { + logger.debug('Got a redirect', html); + return false; + } + return true; + }); + } + + async ensureLogin() { + if (await this.loggedIn) return true; + logger.info('retry normal login'); + const [csrf, ftaa, bfaa] = await this.getCsrfToken('/enter'); + const { header } = await this.get('/enter'); + if (header['set-cookie']) { + this.cookie = header['set-cookie']; + } + const res = await this.post('/enter').send({ + csrf_token: csrf, + action: 'enter', + ftaa, + bfaa, + handleOrEmail: this.account.handle, + password: this.account.password, + remember: 'on', + _tta: this.tta(this.getCookie('39ce7')), + }); + const cookie = res.header['set-cookie']; + if (cookie) { + this.cookie = cookie; + } + if (await this.loggedIn) { + logger.success('Logged in'); + return true; + } + return false; + } + + async submitProblem( + id: string, + lang: string, + code: string, + submissionId: number, + next, + end + ) { + const programType = langs_map[lang] || langs_map['C++']; + const comment = programType.comment; + if (comment) { + const msg = `S2OJ Submission #${submissionId} @ ${new Date().getTime()}`; + if (typeof comment === 'string') code = `${comment} ${msg}\n${code}`; + else if (comment instanceof Array) + code = `${comment[0]} ${msg} ${comment[1]}\n${code}`; + } + const [type, contestId, problemId] = parseProblemId(id); + const [csrf, ftaa, bfaa] = await this.getCsrfToken( + type !== 'GYM' ? '/problemset/submit' : `/gym/${contestId}/submit` + ); + logger.debug( + 'Submitting', + id, + programType, + lang, + `(S2OJ Submission #${submissionId})` + ); + // TODO: check submit time to ensure submission + const { text: submit } = await this.post( + `/${ + type !== 'GYM' ? 'problemset' : `gym/${contestId}` + }/submit?csrf_token=${csrf}` + ).send({ + csrf_token: csrf, + action: 'submitSolutionFormSubmitted', + programTypeId: programType.id, + source: code, + tabsize: 4, + sourceFile: '', + ftaa, + bfaa, + _tta: this.tta(this.getCookie('39ce7')), + ...(type !== 'GYM' + ? { + submittedProblemCode: contestId + problemId, + sourceCodeConfirmed: true, + } + : { + submittedProblemIndex: problemId, + }), + }); + const { + window: { document: statusDocument }, + } = new JSDOM(submit); + const message = Array.from(statusDocument.querySelectorAll('.error')) + .map(i => i.textContent) + .join('') + .replace(/ /g, ' ') + .trim(); + if (message) { + end({ error: true, status: 'Compile Error', message }); + return null; + } + const { text: status } = await this.get( + type !== 'GYM' ? '/problemset/status?my=on' : `/gym/${contestId}/my` + ).retry(3); + const { + window: { document }, + } = new JSDOM(status); + this.csrf = document + .querySelector('meta[name="X-Csrf-Token"]') + .getAttribute('content'); + return document + .querySelector('[data-submission-id]') + .getAttribute('data-submission-id'); + } + + async waitForSubmission(id: string, next, end) { + let i = 1; + + while (true) { + await sleep(3000); + const { body } = await this.post('/data/submitSource') + .send({ + csrf_token: this.csrf, + submissionId: id, + }) + .retry(3); + if (body.compilationError === 'true') { + return await end({ + id, + error: 1, + status: 'Compile Error', + message: body['checkerStdoutAndStderr#1'], + }); + } + const time = mathSum( + Object.keys(body) + .filter(k => k.startsWith('timeConsumed#')) + .map(k => +body[k]) + ); + const memory = + Math.max( + ...Object.keys(body) + .filter(k => k.startsWith('memoryConsumed#')) + .map(k => +body[k]) + ) / 1024; + await next({ test_id: body.testCount }); + if (body.waiting === 'true') continue; + const status = + VERDICT[ + Object.keys(VERDICT).find(k => normalize(body.verdict).includes(k)) + ]; + return await end({ + id, + status, + score: status === 'Accepted' ? 100 : 0, + time, + memory, + }); + } + } +} diff --git a/remote_judger/src/utils/htmlspecialchars.ts b/remote_judger/src/utils/htmlspecialchars.ts new file mode 100644 index 0000000..d300a3e --- /dev/null +++ b/remote_judger/src/utils/htmlspecialchars.ts @@ -0,0 +1,11 @@ +export default function htmlspecialchars(text: string) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + + return text.replace(/[&<>"']/g, m => map[m]); +} diff --git a/remote_judger/src/verdict.ts b/remote_judger/src/verdict.ts new file mode 100644 index 0000000..90527fc --- /dev/null +++ b/remote_judger/src/verdict.ts @@ -0,0 +1,32 @@ +export function normalize(key: string) { + return key.toUpperCase().replace(/ /g, '_'); +} + +export const VERDICT = new Proxy>( + { + RUNTIME_ERROR: 'Runtime Error', + WRONG_ANSWER: 'Wrong Answer', + OK: 'Accepted', + COMPILING: 'Compiling', + TIME_LIMIT_EXCEEDED: 'Time Limit Exceeded', + MEMORY_LIMIT_EXCEEDED: 'Memory Limit Exceeded', + IDLENESS_LIMIT_EXCEEDED: 'Idleness Limit Exceeded', + ACCEPTED: 'Accepted', + PRESENTATION_ERROR: 'Wrong Answer', + OUTPUT_LIMIT_EXCEEDED: 'Output Limit Exceeded', + EXTRA_TEST_PASSED: 'Accepted', + COMPILE_ERROR: 'Compile Error', + 'RUNNING_&_JUDGING': 'Judging', + + // Codeforces + 'HAPPY_NEW_YEAR!': 'Accepted', + }, + { + get(self, key) { + if (typeof key === 'symbol') return null; + key = normalize(key); + if (self[key]) return self[key]; + return null; + }, + } +); diff --git a/remote_judger/src/vjudge.ts b/remote_judger/src/vjudge.ts new file mode 100644 index 0000000..fb9e900 --- /dev/null +++ b/remote_judger/src/vjudge.ts @@ -0,0 +1,155 @@ +import type { BasicProvider, IBasicProvider, RemoteAccount } from './interface'; +import * as Time from './utils/time'; +import Logger from './utils/logger'; +import htmlspecialchars from './utils/htmlspecialchars'; + +const logger = new Logger('vjudge'); + +class AccountService { + api: IBasicProvider; + + constructor( + public Provider: BasicProvider, + public account: RemoteAccount, + private request: any + ) { + this.api = new Provider(account); + this.main().catch(e => + logger.error(`Error occured in ${account.type}/${account.handle}`, e) + ); + } + + async judge( + id: number, + problem_id: string, + language: string, + code: string, + judge_time: string + ) { + const next = async payload => { + return await this.request('/submit', { + 'update-status': true, + fetch_new: false, + id, + status: `Judging Test #${payload.test_id}`, + }); + }; + + const end = async payload => { + if (payload.error) { + return await this.request('/submit', { + submit: true, + fetch_new: false, + id, + result: JSON.stringify({ + status: 'Judged', + score: 0, + error: payload.status, + details: + '
' + + `ID = ${payload.id || 'None'}` + + `${htmlspecialchars(payload.message)}` + + '
', + }), + judge_time, + }); + } + + return await this.request('/submit', { + submit: true, + fetch_new: false, + id, + result: JSON.stringify({ + status: 'Judged', + score: payload.score, + time: payload.time, + memory: payload.memory, + details: + '
' + + `ID = ${payload.id || 'None'}` + + `VERDICT = ${payload.status}` + + '
', + }), + judge_time, + }); + }; + + try { + const rid = await this.api.submitProblem( + problem_id, + language, + code, + id, + next, + end + ); + + if (!rid) return; + + await this.api.waitForSubmission(rid, next, end); + } catch (e) { + logger.error(e); + await end({ error: true, message: e.message }); + } + } + + async login() { + const login = await this.api.ensureLogin(); + if (login === true) { + logger.info(`${this.account.type}/${this.account.handle}: logged in`); + return true; + } + logger.warn( + `${this.account.type}/${this.account.handle}: login fail`, + login || '' + ); + return false; + } + + async main() { + const res = await this.login(); + if (!res) return; + setInterval(() => this.login(), Time.hour); + } +} + +class VJudge { + private providers: Record = {}; + + constructor(private request: any) {} + + async addProvider(type: string) { + if (this.providers[type]) throw new Error(`duplicate provider ${type}`); + const provider = await import(`./providers/${type}`); + const account = provider.getAccountInfoFromEnv(); + + if (!account) throw new Error(`no account info for ${type}`); + + this.providers[type] = new AccountService( + provider.default, + account, + this.request + ); + } + + async judge( + id: number, + type: string, + problem_id: string, + language: string, + code: string, + judge_time: string + ) { + if (!this.providers[type]) throw new Error(`no provider ${type}`); + + this.providers[type].judge(id, problem_id, language, code, judge_time); + } +} + +export async function apply(request: any) { + const vjudge = new VJudge(request); + + await vjudge.addProvider('codeforces'); + + return vjudge; +} diff --git a/remote_judger/tsconfig.json b/remote_judger/tsconfig.json index 6eaa6c6..48b6c8c 100644 --- a/remote_judger/tsconfig.json +++ b/remote_judger/tsconfig.json @@ -13,9 +13,6 @@ "isolatedModules": true, "incremental": true, "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, "outDir": "./dist" }, "include": ["**/*.ts"], diff --git a/web/app/libs/uoj-html-lib.php b/web/app/libs/uoj-html-lib.php index 02242c0..3de8d1e 100644 --- a/web/app/libs/uoj-html-lib.php +++ b/web/app/libs/uoj-html-lib.php @@ -687,7 +687,7 @@ class JudgmentDetailsPrinter { } echo '

', $node->getAttribute("title"), ":

"; } - echo '
', "\n";
+			echo '
', "\n";
 			$this->_print_c($node);
 			echo "\n
"; echo '';