diff --git a/README.md b/README.md
index 2d868c970..8bb70f49e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Hydro
-
+



@@ -11,6 +11,13 @@ Hydro 是一个高效的信息学在线测评系统。特点:易于部署(
我们提供了一套在线服务供用户直接使用或是体验系统功能而无需自行部署安装。
详情前往 [https://hydro.ac](https://hydro.ac) 查看 [操作指引](https://hydro.ac/discuss/6172ceeed850d38c79ae18f9)
+
+一键部署脚本(兼容多数主流 Linux 发行版,推荐使用 Ubuntu 22.04,详见下方文档):
+
+```sh
+LANG=zh . <(curl https://hydro.ac/setup.sh)
+```
+
[中文文档](https://hydro.js.org/)
[English](./README-EN.md)
@@ -30,6 +37,84 @@ Hydro 用户群:1085853538
Telegram 群 [@hydrodev](https://t.me/hydrodev)
Telegram [@webpack_exports_undefined](https://t.me/webpack_exports_undefined)
+
+更新日志(点击展开)
+
+### Hydro 4.4.5 / UI 4.43.0
+- core: 修复比赛基于 ID 搜索题目的功能
+- judge: 修复 testlib 错误信息显示异常的问题
+- sandbox: 提高默认 stdio 限制
+- core: 修复讨论历史记录异常的问题
+- core: 优化每日任务的运行速度
+- core: 用户详情页支持显示用户近期参加的比赛/作业
+- judge: 将 Bash 添加到预设语言列表
+- vjudge: 在 cli 模式下跳过加载
+- lsp: 修复了自动补全的提示,可能需要手动更新后生效
+- judge: 优化 diff 输出
+- install: 默认使用 mongodb uri 作为数据库连接方式
+- ui: 在用户背景加载失败时 fallback 到默认背景
+- 文件路径更改为大小写敏感。
+- 在前端插件中支持使用 `import { ... } from '@hydrooj/ui-default'` 引入内置库。
+- `ctx.inject('Notification')` 支持插入多行文本。
+
+### 4.4.3
+- core: 优化了比赛计分板页面的性能
+- core: 导入用户时支持指定用户所属小组和学校
+- core&ui: 其他漏洞修复和性能优化
+- 添加了 `UserModel.getListForRender(domainId, uids)` 方法。
+- 添加 `IHandler.response.pjax` 属性。
+
+### 4.4.0
+- core: 移除了 Problem.assign
+- core: 修复了比赛结束后,若题目仍处于隐藏状态,无法查看代码的问题
+- ui: 修复了 IE 浏览器端页脚的显示
+- judge: 修复 lemon checker 异常退出导致题目计分为 0 的问题
+- ui: 优化管理端的 Firefox 兼容性警告
+- ui: 优化 fps 题目导入后的显示
+- ui: 修复 IE 浏览器显示语言识别的问题
+- install: 检测已安装的宝塔环境并抛出不兼容警告
+- ui: 优化部分错误提示
+- migrate: 性能优化
+- vjudge: 修复 Codeforces 提交记录爬取异常的问题
+- `ProblemModel.getList()` 移除了 group 参数,后续参数前移
+- `cordis` 升级至 2.6
+
+### 4.3.2
+- 修复评测详情页面在特定情况下不会即时更新的问题
+- 将 testlib spj 的错误返回至用户侧
+- 修复题目文件无法从管理员侧预览的问题
+
+### 4.3.1
+- 终止对 NodeJS <14 的支持
+- ui: api: 更新了 API Workbench
+- judge: 移除环境变量中 \r,添加 Python Packages 说明
+- ui: 修改了部分推荐链接
+- prom-client: 记录 EventEmitter 信息
+- core: contest: 支持导出比赛信息为 Ghost 格式
+- core: contest: 优化比赛中提交量和通过量的计算
+- core: contest: 封榜时显示 Pending 提交
+- judge: 修复客观题未设置答案导致评测跳过的问题
+- core: 优化 CsrfTokenError 和 DomainNotFoundError 回显
+- core: server: 捕获 WebSocket 错误
+- core: validator: 修复可以发送空站内消息的问题
+- 其他漏洞修复和性能优化
+- 在题目详情页中,Scratchpad.store 可从 Window 上公开访问
+
+### 4.3.0
+- 安装时自动安装 Caddy 配置反向代理监听 80 端口。
+- 支持使用 `hydrooj install ` 和 `hydrooj uninstall ` 快速管理插件。
+- 在 管理域 -> 编辑域资料 处添加了语言选择的自动补全。
+- 支持在 OI 赛制下查看自己已提交的代码。
+- import-qduoj:支持导入 SPJ 题目。
+- fps-importer:适配 FPS 文件 1.4 版本。
+- 其他漏洞修复和体验优化。
+- 支持使用 `ctx.i18n.load(lang, Record)` 加载翻译文件。
+- 支持 `ctx.withHandlerClass(name, callback)` 获取类原型。
+- prom-client: 支持自定义 ConnectionHandler 上报分类。
+- 将 Handler.ctx 移动至 Handler.context,新的 Handler.ctx 为 PluginContext。
+
+
+
## 开源许可
本项目基于 AGPL-3.0-or-later 开源。
diff --git a/packages/fps-importer/index.ts b/packages/fps-importer/index.ts
index 40a877bda..115857f33 100644
--- a/packages/fps-importer/index.ts
+++ b/packages/fps-importer/index.ts
@@ -70,7 +70,7 @@ class FpsProblemImportHandler extends Handler {
let c = node;
if (node.$?.name) {
id = node.$.name;
- c = node._;
+ c = node._ || '';
}
tasks.push(ProblemModel.addTestdata(domainId, pid, `${id}.${ext}`, Buffer.from(c)));
};
diff --git a/packages/hydrojudge/src/sandbox.ts b/packages/hydrojudge/src/sandbox.ts
index dae24667f..b0998da1f 100644
--- a/packages/hydrojudge/src/sandbox.ts
+++ b/packages/hydrojudge/src/sandbox.ts
@@ -40,6 +40,8 @@ interface Parameter {
env?: Record;
/** redirect stdin & stdout */
filename?: string;
+ /** In megabytes */
+ stdioLimit?: number;
}
interface SandboxAdaptedResult {
@@ -72,7 +74,7 @@ function proc(params: Parameter): Cmd {
const copyOut = supportOptional
? params.copyOut
: (params.copyOut || []).map((i) => (i.endsWith('?') ? i.substring(0, i.length - 1) : i));
- const size = parseMemoryMB(getConfig('stdio_size'));
+ const size = params.stdioLimit || params.cacheStdoutAndStderr ? parseMemoryMB(getConfig('stdio_size')) : 4;
const rate = getConfig('rate');
const copyOutCached = [...(params.copyOutCached || [])];
if (params.cacheStdoutAndStderr) copyOutCached.push(params.filename ? `${params.filename}.out?` : 'stdout', 'stderr');
diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts
index 7e7f1b613..d1e356275 100644
--- a/packages/hydrooj/src/handler/contest.ts
+++ b/packages/hydrooj/src/handler/contest.ts
@@ -3,7 +3,9 @@ import { statSync } from 'fs-extra';
import { pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectID } from 'mongodb';
-import { Counter, sortFiles, Time } from '@hydrooj/utils/lib/utils';
+import {
+ Counter, sortFiles, streamToBuffer, Time,
+} from '@hydrooj/utils/lib/utils';
import {
BadRequestError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, ForbiddenError,
@@ -506,9 +508,18 @@ export class ContestCodeHandler extends Handler {
const rdocs = await record.getMulti(domainId, {
_id: { $in: Array.from(Object.keys(rnames)).map((id) => new ObjectID(id)) },
}).toArray();
- for (const rdoc of rdocs) {
- zip.addFile(`${rnames[rdoc._id.toHexString()]}.${rdoc.lang}`, Buffer.from(rdoc.code));
- }
+ await Promise.all(rdocs.map(async (rdoc) => {
+ if (rdoc.files?.code) {
+ const [id, filename] = rdoc.files?.code?.split('#') || [];
+ if (!id) return;
+ zip.addFile(
+ `${rnames[rdoc._id.toHexString()]}.${filename || 'txt'}`,
+ await streamToBuffer(storage.get(id)),
+ );
+ } else if (rdoc.code) {
+ zip.addFile(`${rnames[rdoc._id.toHexString()]}.${rdoc.lang}`, Buffer.from(rdoc.code));
+ }
+ }));
this.binary(zip.toBuffer(), `${tdoc.title}.zip`);
}
}
diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts
index 82a2f0751..d204a31fd 100644
--- a/packages/hydrooj/src/handler/problem.ts
+++ b/packages/hydrooj/src/handler/problem.ts
@@ -444,6 +444,7 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
contest: { $ne: new ObjectID('0'.repeat(24)) },
'files.hack': { $exists: false },
}).project({ _id: 1, contest: 1 }).toArray();
+ if (!this.pdoc.config || typeof this.pdoc.config === 'string') throw new ProblemConfigError();
const priority = await record.submissionPriority(this.user._id, -10000 - rdocs.length * 5 - 50);
await record.reset(domainId, rdocs.map((rdoc) => rdoc._id), true);
await Promise.all([
@@ -527,13 +528,13 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
if (!file || file.size === 0) throw new ValidationError('code');
const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : 65535;
if (file.size > sizeLimit) throw new ValidationError('file');
- if (file.size > 65535 || await isBinaryFile(file.filepath, file.size)) {
+ if (config.type !== 'submit_answer' || (file.size < 65535 && !await isBinaryFile(file.filepath, file.size))) {
+ // TODO auto detect & convert encoding
+ code = await readFile(file.filepath, 'utf-8');
+ } else {
const id = nanoid();
await storage.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id);
files.code = `${this.user._id}/${id}#${file.originalFilename}`;
- } else {
- // TODO auto detect & convert encoding
- code = await readFile(file.filepath, 'utf-8');
}
}
const rid = await record.add(
diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts
index 9b74379bc..effd3568b 100644
--- a/packages/hydrooj/src/handler/record.ts
+++ b/packages/hydrooj/src/handler/record.ts
@@ -1,7 +1,8 @@
import { omit, pick, throttle } from 'lodash';
import { FilterQuery, ObjectID } from 'mongodb';
import {
- ContestNotAttendedError, ContestNotFoundError, HackRejudgeFailedError, PermissionError,
+ ContestNotAttendedError, ContestNotFoundError, HackRejudgeFailedError,
+ PermissionError, ProblemConfigError,
ProblemNotFoundError, RecordNotFoundError, UserNotFoundError,
} from '../error';
import { RecordDoc, Tdoc } from '../interface';
@@ -191,6 +192,8 @@ class RecordDetailHandler extends ContestDetailBaseHandler {
@param('rid', Types.ObjectID)
async postRejudge(domainId: string, rid: ObjectID) {
+ const pdoc = await problem.get(domainId, this.rdoc.pid);
+ if (!pdoc?.config || typeof pdoc.config === 'string') throw new ProblemConfigError();
const priority = await record.submissionPriority(this.user._id, -20);
const isContest = this.rdoc.contest && this.rdoc.contest.toString() !== '000000000000000000000000';
await record.reset(domainId, rid, true);
diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts
index c46542c82..d8c64ae52 100644
--- a/packages/hydrooj/src/handler/user.ts
+++ b/packages/hydrooj/src/handler/user.ts
@@ -129,7 +129,7 @@ class UserLoginHandler extends Handler {
if (udoc._tfa && !verifyToken(udoc._tfa, tfa)) throw new InvalidTokenError('2FA');
udoc.checkPassword(password);
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
- if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname);
+ if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname, udoc.banReason);
this.session.viewLang = '';
this.session.uid = udoc._id;
this.session.sudo = null;
@@ -471,5 +471,5 @@ export async function apply(ctx) {
ctx.Route('user_lostpass', '/lostpass', UserLostPassHandler);
ctx.Route('user_lostpass_with_code', '/lostpass/:code', UserLostPassWithCodeHandler);
ctx.Route('user_delete', '/user/delete', UserDeleteHandler, PRIV.PRIV_USER_PROFILE);
- ctx.Route('user_detail', '/user/:uid', UserDetailHandler);
+ ctx.Route('user_detail', '/user/:uid(-?\\d+)', UserDetailHandler);
}
diff --git a/packages/hydrooj/src/model/record.ts b/packages/hydrooj/src/model/record.ts
index d9e4a582d..85ba8bc7c 100644
--- a/packages/hydrooj/src/model/record.ts
+++ b/packages/hydrooj/src/model/record.ts
@@ -232,7 +232,7 @@ export default class RecordModel {
export function apply(ctx: Context) {
// Mark problem as deleted
- ctx.on('problem/delete', (domainId, docId) => RecordModel.coll.updateMany({ domainId, pid: docId }, { $set: { pid: -1 } }));
+ ctx.on('problem/delete', (domainId, docId) => RecordModel.coll.deleteMany({ domainId, pid: docId }));
ctx.on('domain/delete', (domainId) => RecordModel.coll.deleteMany({ domainId }));
ctx.on('ready', () => db.ensureIndexes(
RecordModel.coll,
diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts
index ea60bcef9..811748439 100644
--- a/packages/hydrooj/src/model/setting.ts
+++ b/packages/hydrooj/src/model/setting.ts
@@ -152,6 +152,7 @@ AccountSetting(
'Choose the background image in your profile page.'),
Setting('setting_storage', 'unreadMsg', 0, 'number', 'Unread Message Count', null, FLAG_DISABLED | FLAG_HIDDEN),
Setting('setting_storage', 'badge', '', 'text', 'badge info', null, FLAG_DISABLED | FLAG_HIDDEN),
+ Setting('setting_storage', 'banReason', '', 'text', 'ban reason', null, FLAG_DISABLED | FLAG_HIDDEN),
);
DomainSetting(
diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts
index 5df7d011a..75a6f24e9 100644
--- a/packages/hydrooj/src/model/user.ts
+++ b/packages/hydrooj/src/model/user.ts
@@ -272,8 +272,7 @@ class UserModel {
if (_id < -999) return null;
const udoc = await coll.findOne({ _id });
if (!udoc) throw new UserNotFoundError(_id);
- udoc[field] = udoc[field] + n || n;
- await coll.updateOne({ _id }, { $set: { [field]: udoc[field] } });
+ await coll.updateOne({ _id }, { $inc: { [field]: n } });
deleteUserCache(udoc);
return udoc;
}
@@ -401,9 +400,9 @@ class UserModel {
}
@ArgMethod
- static ban(uid: number) {
+ static ban(uid: number, reason = '') {
return Promise.all([
- UserModel.setPriv(uid, PRIV.PRIV_NONE),
+ UserModel.setById(uid, { priv: PRIV.PRIV_NONE, banReason: reason }),
token.delByUid(uid),
]);
}
diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts
index 557ccfef6..3414a982c 100644
--- a/packages/hydrooj/src/service/server.ts
+++ b/packages/hydrooj/src/service/server.ts
@@ -1,5 +1,6 @@
import http from 'http';
-import { resolve } from 'path';
+import { tmpdir } from 'os';
+import { join, resolve } from 'path';
import cac from 'cac';
import fs from 'fs-extra';
import Koa from 'koa';
@@ -484,14 +485,22 @@ export async function apply(pluginContext: Context) {
${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`);
});
}
+ const uploadDir = join(tmpdir(), 'hydro', 'upload', process.env.NODE_APP_INSTANCE || '0');
+ fs.ensureDirSync(uploadDir);
+ logger.debug('Using upload dir: %s', uploadDir);
app.use(Body({
multipart: true,
jsonLimit: '8mb',
formLimit: '8mb',
formidable: {
+ uploadDir,
maxFileSize: parseMemoryMB(system.get('server.upload') || '256m') * 1024 * 1024,
+ keepExtensions: true,
},
}));
+ pluginContext.on('app/exit', () => {
+ fs.emptyDirSync(uploadDir);
+ });
const layers = [baseLayer, rendererLayer(router, logger), responseLayer(logger), userLayer];
app.use(async (ctx, next) => await next().catch(console.error)).use(domainLayer);
app.use(router.routes()).use(router.allowedMethods());
diff --git a/packages/hydrooj/src/service/storage.ts b/packages/hydrooj/src/service/storage.ts
index a9111ee67..e43d23340 100644
--- a/packages/hydrooj/src/service/storage.ts
+++ b/packages/hydrooj/src/service/storage.ts
@@ -60,6 +60,14 @@ export function encodeRFC5987ValueChars(str: string) {
);
}
+const convertPath = (p: string) => {
+ p = p.trim();
+ if (p.includes('..') || p.includes('//') || p.endsWith('/.') || p === '.' || p.includes('/./')) {
+ throw new Error('Invalid path');
+ }
+ return p;
+};
+
class RemoteStorageService {
public client: S3Client;
public error = '';
@@ -113,7 +121,7 @@ class RemoteStorageService {
}
async put(target: string, file: string | Buffer | Readable, meta: Record = {}) {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
+ target = convertPath(target);
if (typeof file === 'string') file = createReadStream(file);
const params: PutObjectCommandInput = {
Bucket: this.opts.bucket,
@@ -138,7 +146,7 @@ class RemoteStorageService {
}
async get(target: string, path?: string) {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
+ target = convertPath(target);
const res = await this.client.send(new GetObjectCommand({
Bucket: this.opts.bucket,
Key: target,
@@ -163,9 +171,8 @@ class RemoteStorageService {
}
async del(target: string | string[]) {
- if (typeof target === 'string') {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
- } else if (target.find((t) => t.includes('..') || t.includes('//'))) throw new Error('Invalid path');
+ if (typeof target === 'string') target = convertPath(target);
+ else target = target.map(convertPath);
if (typeof target === 'string') {
return await this.client.send(new DeleteObjectCommand({
Bucket: this.opts.bucket,
@@ -186,7 +193,7 @@ class RemoteStorageService {
}
async getMeta(target: string) {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
+ target = convertPath(target);
const res = await this.client.send(new HeadObjectCommand({
Bucket: this.opts.bucket,
Key: target,
@@ -200,7 +207,7 @@ class RemoteStorageService {
}
async signDownloadLink(target: string, filename?: string, noExpire = false, useAlternativeEndpointFor?: 'user' | 'judge'): Promise {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
+ target = convertPath(target);
const url = await getSignedUrl(this.client, new GetObjectCommand({
Bucket: this.opts.bucket,
Key: target,
@@ -234,8 +241,8 @@ class RemoteStorageService {
}
async copy(src: string, target: string) {
- if (src.includes('..') || src.includes('//')) throw new Error('Invalid path');
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
+ src = convertPath(src);
+ target = convertPath(target);
return await this.client.send(new CopyObjectCommand({
Bucket: this.opts.bucket,
Key: target,
@@ -262,8 +269,7 @@ class LocalStorageService {
}
async put(target: string, file: string | Buffer | Readable) {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
- target = resolve(this.dir, target);
+ target = resolve(this.dir, convertPath(target));
await ensureDir(dirname(target));
if (typeof file === 'string') await copyFile(file, target);
else if (file instanceof Buffer) await writeFile(target, file);
@@ -271,22 +277,19 @@ class LocalStorageService {
}
async get(target: string, path?: string) {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
- target = resolve(this.dir, target);
+ target = resolve(this.dir, convertPath(target));
if (!existsSync(target)) throw new Error('File not found');
if (path) await copyFile(target, path);
return createReadStream(target);
}
async del(target: MaybeArray) {
- const targets = typeof target === 'string' ? [target] : target;
- if (targets.find((i) => i.includes('..') || i.includes('//'))) throw new Error('Invalid path');
+ const targets = (typeof target === 'string' ? [target] : target).map(convertPath);
await Promise.all(targets.map((i) => remove(resolve(this.dir, i))));
}
async getMeta(target: string) {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
- target = resolve(this.dir, target);
+ target = resolve(this.dir, convertPath(target));
const file = await stat(target);
return {
size: file.size,
@@ -302,7 +305,7 @@ class LocalStorageService {
}
async signDownloadLink(target: string, filename = '', noExpire = false, useAlternativeEndpointFor?: 'user' | 'judge'): Promise {
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
+ target = convertPath(target);
const url = new URL('https://localhost/storage');
url.searchParams.set('target', target);
if (filename) url.searchParams.set('filename', filename);
@@ -322,10 +325,8 @@ class LocalStorageService {
}
async copy(src: string, target: string) {
- if (src.includes('..') || src.includes('//')) throw new Error('Invalid path');
- if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
- src = resolve(this.dir, src);
- target = resolve(this.dir, target);
+ src = resolve(this.dir, convertPath(src));
+ target = resolve(this.dir, convertPath(target));
await copyFile(src, target);
return { etag: target, lastModified: new Date() };
}
diff --git a/packages/hydrooj/src/ui.ts b/packages/hydrooj/src/ui.ts
index 3a29c5225..9372c9002 100644
--- a/packages/hydrooj/src/ui.ts
+++ b/packages/hydrooj/src/ui.ts
@@ -2,8 +2,14 @@
import { Logger } from './logger';
import * as bus from './service/bus';
+let terminating = false;
async function terminate() {
+ if (terminating) process.exit(1);
let hasError = false;
+ terminating = true;
+ setTimeout(() => {
+ new Logger('exit').info('Cleaning up temporary files... (Press Ctrl-C again to force exit)');
+ }, 1000);
try {
await bus.parallel('app/exit');
} catch (e) {
@@ -12,6 +18,7 @@ async function terminate() {
process.exit(hasError ? 1 : 0);
}
process.on('SIGINT', terminate);
+process.on('SIGTERM', terminate);
const shell = new Logger('shell');
async function executeCommand(input: string) {
diff --git a/packages/import-qduoj/index.ts b/packages/import-qduoj/index.ts
index 45b8d4fcd..5055f0e40 100644
--- a/packages/import-qduoj/index.ts
+++ b/packages/import-qduoj/index.ts
@@ -20,9 +20,11 @@ class ImportQduojHandler extends Handler {
await new Promise((resolve, reject) => {
zip.extractAllToAsync(tmp, true, (err) => (err ? reject(err) : resolve(null)));
});
+ let cnt = 0;
try {
- const folders = await fs.readdir(tmp);
- for (const folder of folders) {
+ const folders = await fs.readdir(tmp, { withFileTypes: true });
+ for (const { name: folder } of folders.filter((i) => i.isDirectory())) {
+ if (!fs.existsSync(path.join(tmp, folder, 'problem.json'))) continue;
const buf = await fs.readFile(path.join(tmp, folder, 'problem.json'));
const pdoc = JSON.parse(buf.toString());
const content: ContentNode[] = [];
@@ -115,10 +117,12 @@ class ImportQduojHandler extends Handler {
ProblemModel.edit(domainId, pid, { html: true }),
);
await Promise.all(tasks);
+ cnt++;
}
} finally {
await fs.remove(tmp);
}
+ if (!cnt) throw new ValidationError('zip', 'No problemset imported');
}
async get() {
diff --git a/packages/ui-default/api.ts b/packages/ui-default/api.ts
index 347673750..2eae5c6c5 100644
--- a/packages/ui-default/api.ts
+++ b/packages/ui-default/api.ts
@@ -28,7 +28,6 @@ import UserSelectAutoComplete from './components/autocomplete/UserSelectAutoComp
export {
load, AutoComplete, UserSelectAutoComplete, ProblemSelectAutoComplete, DomainSelectAutoComplete, CustomSelectAutoComplete,
};
-export const { UserContext, UiContext } = window;
export function addPage(page: import('./misc/Page').Page | (() => Promise | void)) {
window.Hydro.extraPages.push(page);
}
diff --git a/packages/ui-default/components/star/star.page.js b/packages/ui-default/components/star/star.page.ts
similarity index 84%
rename from packages/ui-default/components/star/star.page.js
rename to packages/ui-default/components/star/star.page.ts
index 5fbd64405..60984501b 100644
--- a/packages/ui-default/components/star/star.page.js
+++ b/packages/ui-default/components/star/star.page.ts
@@ -2,7 +2,7 @@ import $ from 'jquery';
import { AutoloadPage } from 'vj/misc/Page';
import { request } from 'vj/utils';
-function setStarButtonState($starButton, star) {
+function setStarButtonState($starButton: JQuery, star: boolean) {
if (star) {
$starButton.addClass('activated');
} else {
@@ -12,11 +12,12 @@ function setStarButtonState($starButton, star) {
const starPage = new AutoloadPage('starPage', () => {
$(document).on('click', '.star', (ev) => {
+ ev.preventDefault();
const $button = $(ev.currentTarget);
const currentState = $button.hasClass('activated');
const $form = $button.closest('form');
const $op = $form.find('[name="operation"]');
- if (!['star', 'unstar'].includes($op.val())) return;
+ if (!['star', 'unstar'].includes($op.val() as any)) return;
$op.val(currentState ? 'unstar' : 'star');
setStarButtonState($button, !currentState);
request
diff --git a/packages/vjudge/src/providers/codeforces.ts b/packages/vjudge/src/providers/codeforces.ts
index b39a827ae..4724b8b40 100644
--- a/packages/vjudge/src/providers/codeforces.ts
+++ b/packages/vjudge/src/providers/codeforces.ts
@@ -157,28 +157,6 @@ export default class CodeforcesProvider implements IBasicProvider {
return _tta;
}
- async checkLogin() {
- await this.ensureBrowser();
- const page = await this.getPage();
- await page.goto(`${this.account.endpoint}/enter`, { waitUntil: 'networkidle2', timeout: 180000 });
- const html = await page.content();
- let cookies = await page.cookies();
- let c = 0;
- while (!cookies.find((i) => i.name === 'evercookie_etag').value && c <= 60) {
- await sleep(1000);
- cookies = await page.cookies();
- c++;
- }
- if (c < 60) {
- this.cookie = cookies.map((i) => `${i.name}=${i.value}`);
- await this.save({ cookie: this.cookie });
- }
- const ftaa = cookies.find((i) => i.name === '70a7c28f3de')?.value;
- const bfaa = /_bfaa = "(.{32})"/.exec(html)?.[1];
- await page.close();
- return [ftaa, bfaa, !html.includes('Login into Codeforces')];
- }
-
async getCsrfToken(url: string) {
const { text: html } = await this.get(url);
const { window: { document } } = new JSDOM(html);
@@ -197,9 +175,13 @@ export default class CodeforcesProvider implements IBasicProvider {
}
get loggedIn() {
- return this.get('/enter').then(({ text: html }) => {
+ 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...')) return false;
+ if (html.length < 1000 && html.includes('Redirecting...')) {
+ logger.debug('Got a redirect', html);
+ return false;
+ }
return true;
});
}
@@ -226,7 +208,7 @@ export default class CodeforcesProvider implements IBasicProvider {
await this.ensureBrowser();
if (!this.puppeteer) return false;
const page = await this.puppeteer.newPage();
- await page.goto(`${this.account.endpoint}/enter`, { waitUntil: 'networkidle2' });
+ await page.goto(`${this.account.endpoint}/enter`, { waitUntil: 'networkidle2', timeout: 60000 });
const url = await page.openPortal();
logger.info(`Login portal opened: ${url}`);
await page.waitForRequest((req) => {
@@ -371,7 +353,10 @@ export default class CodeforcesProvider implements IBasicProvider {
if (index !== page.toString()) return [];
}
if (listName === 'main') {
- return Array.from(document.querySelectorAll('.id>a')).map((i) => `P${i.innerHTML.trim()}`);
+ // 1769 is a hidden contest
+ return Array.from(document.querySelectorAll('.id>a'))
+ .map((i) => `P${i.innerHTML.trim()}`)
+ .filter((i) => !['P1772', 'P1769'].find((t) => i.startsWith(t)));
}
if (listName === 'gym') {
return Array.from(document.querySelectorAll('[data-contestId]')).map((i) => `LIST::GYM${i.getAttribute('data-contestId')}`);