diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 3adac0ae9..4551e598e 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "2.17.22", + "version": "2.18.0", "bin": "bin/hydrooj.js", "main": "dist/loader.js", "typings": "dist/loader.d.ts", diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index 58d3a5439..8f3d537e3 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -26,7 +26,7 @@ export const builtinModel = [ export const builtinHandler = [ 'home', 'problem', 'record', 'judge', 'user', 'contest', 'training', 'discussion', 'manage', 'import.syzoj', - 'misc', 'homework', 'domain', + 'misc', 'homework', 'domain', 'remote', ]; export const builtinScript = [ diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 286ca7d62..1f8f125d4 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -1,24 +1,19 @@ import { isSafeInteger, flatten } from 'lodash'; import yaml from 'js-yaml'; -import fs from 'fs-extra'; import { FilterQuery, ObjectID } from 'mongodb'; -import superagent from 'superagent'; import AdmZip from 'adm-zip'; import { - NoProblemError, BadRequestError, PermissionError, - SolutionNotFoundError, ProblemNotFoundError, ValidationError, + NoProblemError, PermissionError, ValidationError, + SolutionNotFoundError, ProblemNotFoundError, } from '../error'; import { Pdoc, User, Rdoc, PathComponent, } from '../interface'; import paginate from '../lib/paginate'; import { isTitle, isContent, isPid } from '../lib/validator'; -import { ProblemAdd } from '../lib/ui'; import * as problem from '../model/problem'; import * as record from '../model/record'; import * as domain from '../model/domain'; -import * as system from '../model/system'; -import * as token from '../model/token'; import * as user from '../model/user'; import * as solution from '../model/solution'; import { PERM, PRIV, CONSTANT } from '../model/builtin'; @@ -623,63 +618,7 @@ export class ProblemCreateHandler extends Handler { } } -export class ProblemImportHandler extends Handler { - async get() { - this.response.body = { type: 'Hydro' }; - this.response.template = 'problem_import.html'; - } - - async post({ domainId }) { - if (!this.request.files.file) throw new ValidationError('file'); - const stat = await fs.stat(this.request.files.file.path); - if (stat.size > 128 * 1024 * 1024) throw new BadRequestError('File too large'); - const zip = new AdmZip(this.request.files.file.path); - const pdoc = JSON.parse(zip.getEntry('problem.json').getData().toString()); - const pid = await problem.add(domainId, pdoc.pid, pdoc.title, pdoc.content, this.user._id, pdoc.tags, pdoc.category); - const entries = zip.getEntries(); - for (const entry of entries) { - if (entry.name.startsWith('testdata/')) { - // eslint-disable-next-line no-await-in-loop - await problem.addTestdata(domainId, pid, entry.name.split('testdata/')[1], entry.getData()); - } else if (entry.name.startsWith('additional_file/')) { - // eslint-disable-next-line no-await-in-loop - await problem.addAdditionalFile(domainId, pid, entry.name.split('testdata/')[1], entry.getData()); - } - } - await problem.edit(domainId, pid, { html: pdoc.html }); - this.response.redirect = this.url('problem_detail', { pid }); - } -} - -export class ProblemSendHandler extends Handler { - async get() { - this.response.template = 'problem_send.html'; - } - - @post('target', Types.String) - @post('pids', Types.Array) - async post(domainId: string, target: any, pids: number[]) { - target = target.split('@'); - const getHidden = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); - const pdocs = await problem.getList(domainId, pids, getHidden, true); - const getData = this.user.hasPerm(PERM.PERM_READ_PROBLEM_DATA); - if (!getData) { - for (const pid in pdocs) { - if (pdocs[pid].owner !== this.user._id) { - throw new PermissionError(PERM.PERM_READ_PROBLEM_DATA); - } - } - } - const [source, expire] = system.getMany(['server.url', 'session.expire_seconds']); - const tokenId = await token.createOrUpdate(token.TYPE_EXPORT, expire, { domainId, pids }); - await superagent.post(`${target[1]}/d/${target[0]}/problem/receive`) - .send({ source, domainId, tokenId }); - this.back(); - } -} - export async function apply() { - ProblemAdd('problem_import', {}, 'copy', 'Import From Hydro'); Route('problem_main', '/p', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_category', '/p/category/:category', ProblemCategoryHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_random', '/problem/random', ProblemRandomHandler, PERM.PERM_VIEW_PROBLEM); @@ -695,8 +634,6 @@ export async function apply() { Route('problem_solution_raw', '/p/:pid/solution/:psid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_solution_reply_raw', '/p/:pid/solution/:psid/:psrid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_create', '/problem/create', ProblemCreateHandler, PERM.PERM_CREATE_PROBLEM); - Route('problem_import', '/problem/import', ProblemImportHandler, PERM.PERM_CREATE_PROBLEM); - Route('problem_send', '/problem/send', ProblemSendHandler, PERM.PERM_VIEW_PROBLEM); Connection('problem_pretest_conn', '/conn/pretest', ProblemPretestConnectionHandler, PERM.PERM_SUBMIT_PROBLEM); } diff --git a/packages/hydrooj/src/handler/remote.ts b/packages/hydrooj/src/handler/remote.ts new file mode 100644 index 000000000..9850c5555 --- /dev/null +++ b/packages/hydrooj/src/handler/remote.ts @@ -0,0 +1,149 @@ +/* eslint-disable no-await-in-loop */ +import superagent from 'superagent'; +import { PassThrough } from 'stream'; +import { PermissionError, InvalidTokenError } from '../error'; +import { ProblemAdd } from '../lib/ui'; +import { PERM } from '../model/builtin'; +import * as token from '../model/token'; +import * as problem from '../model/problem'; +import * as system from '../model/system'; +import { + Handler, Types, Route, post, +} from '../service/server'; +import storage from '../service/storage'; + +export class ProblemSendHandler extends Handler { + async get() { + this.response.template = 'problem_send.html'; + } + + @post('target', Types.String) + @post('pids', Types.Array) + async postSend(domainId: string, target: any, _pids: (number | string)[]) { + target = target.split('@'); + const getHidden = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + const getData = this.user.hasPerm(PERM.PERM_READ_PROBLEM_DATA); + const pdocs = await problem.getList(domainId, _pids, getHidden, true, ['docId', 'owner']); + const pids = new Set(); + for (const key in pdocs) { + pids.add(pdocs[key].docId); + if (!getData && pdocs[key].owner !== this.user._id) { + throw new PermissionError(PERM.PERM_READ_PROBLEM_DATA); + } + } + const [url, expire] = system.getMany(['server.url', 'session.saved_expire_seconds']); + const tokenId = await token.createOrUpdate(token.TYPE_EXPORT, expire, { domainId, pids: Array.from(pids) }); + await superagent.post(`${target[1]}/d/${target[0]}/problem/receive`) + .send({ + operation: 'request', url: `${url}d/${domainId}/problem/send`, tokenId, expire, + }); + this.back(); + } + + @post('token', Types.String) + async postInfo(domainId: string, tokenId: string) { + const data = await token.get(tokenId, token.TYPE_EXPORT); + if (!data) throw new InvalidTokenError(tokenId); + const r = {}; + const pdocs = await problem.getMulti(domainId, { docId: { $in: data.pids } }, ['title', 'docId']).toArray(); + for (const pdoc of pdocs) r[pdoc.docId] = pdoc.title; + this.response.body = { pdocs: r, pids: data.pids }; + } + + @post('token', Types.String, true) + @post('pids', Types.Array) + async postFetch(domainId: string, tokenId: string, pids: number[]) { + pids = pids.map((i) => (+i ? +i : i)); + const pdocs = await problem.getMulti(domainId, { docId: { $in: pids } }, problem.PROJECTION_PUBLIC).toArray(); + const data = await token.get(tokenId, token.TYPE_EXPORT); + if (!data) throw new InvalidTokenError(tokenId); + for (const pid of pids) if (!data.pids.includes(pid)) throw new InvalidTokenError(tokenId); + const links = []; + for (const pdoc of pdocs) { + const files = await storage.list(`problem/${domainId}/${pdoc.docId}/`, true); + const l = []; + for (const file of files) { + l.push([ + file.name, + await storage.signDownloadLink(file.prefix + file.name, file.name, false, 'user'), + ]); + } + links.push(l); + } + this.response.body = { pdocs, links }; + } +} + +export class ProblemReceiveHandler extends Handler { + async get({ domainId }) { + this.checkPerm(PERM.PERM_CREATE_PROBLEM); + const requests = await token.getMulti(token.TYPE_IMPORT, { domainId }).toArray(); + this.response.template = 'problem_receive.html'; + this.response.body = { requests }; + } + + @post('url', Types.String) + @post('tokenId', Types.String) + @post('expire', Types.UnsignedInt) + async postRequest(domainId: string, url: string, tokenId: string, expire: number) { + const res = await superagent.post(url) + .send({ operation: 'info', token: tokenId }); + const [id] = await token.add(token.TYPE_IMPORT, expire, { + domainId, tokenId, source: url, pdocs: res.body.pdocs, pids: res.body.pids, + }); + this.response.body = { token: id }; + } + + // eslint-disable-next-line class-methods-use-this + async syncFiles(domainId: string, baseUrl: string, pid: number, files: [string, string][]) { + for (const file of files) { + const content = new PassThrough(); + superagent.get(file[1].startsWith('/') ? baseUrl + file[1] : file[1]).pipe(content); + if (file[0].startsWith('testdata/')) { + await problem.addTestdata(domainId, pid, file[0].split('testdata/')[1], content); + } else if (file[0].startsWith('additional_file/')) { + await problem.addAdditionalFile(domainId, pid, file[0].split('additional_file/')[1], content); + } + } + } + + @post('tokenId', Types.String) + @post('pids', Types.Array, true) + async postConfirm(domainId: string, tokenId: string, filterPid?: number[]) { + this.checkPerm(PERM.PERM_CREATE_PROBLEM); + const data = await token.get(tokenId, token.TYPE_IMPORT); + if (!data) throw new InvalidTokenError(tokenId); + const res = await superagent.post(data.source).send({ + operation: 'fetch', + token: data.tokenId, + pids: filterPid.length ? filterPid.map((i) => (+i ? +i : i)) : data.pids, + }); + const tasks = []; + const pids = []; + const { pdocs } = res.body; + const files = res.body.links; + const source = new URL(data.source); + const baseUrl = `${source.protocol}//${source.host}`; + for (let i = 0; i < pdocs.length; i++) { + const pdoc = pdocs[i]; + if (pdoc.pid) { + const exist = await problem.get(domainId, pdoc.pid); + if (exist) pdoc.pid = undefined; + } + const pid = await problem.add(domainId, pdoc.pid, pdoc.title, pdoc.content, this.user._id, pdoc.tag, pdoc.category); + pids.push(pid); + tasks.push(this.syncFiles(domainId, baseUrl, pid, files[i])); + await problem.edit(domainId, pid, { html: pdoc.html }); + } + this.back(); + this.response.body = { pids }; + } +} + +export async function apply() { + ProblemAdd('problem_receive', {}, 'copy', 'Import From Hydro'); + Route('problem_receive', '/problem/receive', ProblemReceiveHandler); + Route('problem_send', '/problem/send', ProblemSendHandler, PERM.PERM_VIEW_PROBLEM); +} + +global.Hydro.handler.remote = apply; diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index b3afc7df9..3525418a4 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -37,7 +37,7 @@ export interface SystemKeys { 'problem.categories': string, 'session.keys': string[], 'session.secure': boolean, - 'session.expire_seconds': number, + 'session.saved_expire_seconds': number, 'session.unsaved_expire_seconds': number, 'user.quota': number, } @@ -408,9 +408,7 @@ export interface ContestRule { } export type ContestRules = Dictionary; - -export type ProblemImporter = (url: string, handler: any) => -Promise<[Pdoc, fs.ReadStream?]> | [Pdoc, fs.ReadStream?]; +export type ProblemImporter = (url: string, handler: any) => Promise<[Pdoc, fs.ReadStream?]> | [Pdoc, fs.ReadStream?]; export interface Script { run: (args: any, report: Function) => any, diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index c51027e81..0a6317187 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -4,6 +4,7 @@ import { Dictionary, pick } from 'lodash'; import { STATUS } from './builtin'; import * as document from './document'; import * as domain from './domain'; +import { buildProjection } from '../utils'; import { ProblemStatusDoc, Pdict } from '../interface'; import { Content } from '../loader'; import { ArrayKeys, NumberKeys, Projection } from '../typeutils'; @@ -193,14 +194,15 @@ export async function random(domainId: string, query: FilterQuery): Promis export async function getList( domainId: string, pids: Array, - getHidden = false, doThrow = true, + getHidden = false, doThrow = true, projection = PROJECTION_PUBLIC, ): Promise { - pids = Array.from(new Set(pids)); + pids = Array.from(new Set(pids)).map((i) => (+i ? +i : i)); const r = {}; const l = {}; const q: any = { $or: [{ docId: { $in: pids } }, { pid: { $in: pids } }] }; if (!getHidden) q.hidden = false; - const pdocs = await document.getMulti(domainId, document.TYPE_PROBLEM, q).toArray(); + const pdocs = await document.getMulti(domainId, document.TYPE_PROBLEM, q) + .project(buildProjection(projection)).toArray(); for (const pdoc of pdocs) { r[pdoc.docId] = pdoc; l[pdoc.pid] = pdoc; diff --git a/packages/hydrooj/src/model/token.ts b/packages/hydrooj/src/model/token.ts index 380c10adf..9d5dfc04a 100644 --- a/packages/hydrooj/src/model/token.ts +++ b/packages/hydrooj/src/model/token.ts @@ -1,3 +1,4 @@ +import { FilterQuery } from 'mongodb'; import { TokenDoc } from '../interface'; import db from '../service/db'; import * as bus from '../service/bus'; @@ -10,6 +11,7 @@ export const TYPE_CHANGEMAIL = 3; export const TYPE_OAUTH = 4; export const TYPE_LOSTPASS = 5; export const TYPE_EXPORT = 6; +export const TYPE_IMPORT = 7; function ensureIndexes() { return Promise.all([ @@ -38,6 +40,10 @@ export async function get(tokenId: string, tokenType: number): Promise = {}) { + return coll.find({ tokenType, ...query }); +} + export async function update( tokenId: string, tokenType: number, expireSeconds: number, data: object, @@ -98,10 +104,12 @@ global.Hydro.model.token = { TYPE_REGISTRATION, TYPE_LOSTPASS, TYPE_EXPORT, + TYPE_IMPORT, add, createOrUpdate, get, + getMulti, update, del, delByUid, diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index a64b09d78..c9ab67347 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -541,7 +541,7 @@ export class Handler extends HandlerCommon { async saveCookie() { const expireSeconds = this.session.save - ? system.get('session.expire_seconds') + ? system.get('session.saved_expire_seconds') : system.get('session.unsaved_expire_seconds'); const $update = { updateIp: this.request.ip,