diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index 3cfeaa496..107ca46fa 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -5,8 +5,8 @@ "main": "base.mjs", "repository": "https://github.com/hydro-dev/Hydro", "dependencies": { - "@antfu/eslint-config": "^6.2.0", - "@eslint-react/eslint-plugin": "^2.3.11", + "@antfu/eslint-config": "^6.4.2", + "@eslint-react/eslint-plugin": "^2.3.12", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", "eslint-plugin-de-morgan": "^2.0.0", diff --git a/packages/migrate/index.ts b/packages/migrate/index.ts index 4b68967d5..ba4b7e4a8 100644 --- a/packages/migrate/index.ts +++ b/packages/migrate/index.ts @@ -1,8 +1,7 @@ import crypto from 'crypto'; +import { compareSync } from 'bcryptjs'; import { - Context, md5, Schema, sha1, - SystemError, - SystemModel, + Context, md5, Schema, sha1, SystemError, SystemModel, } from 'hydrooj'; const RE_MD5 = /^[\da-f]{32}$/; @@ -32,6 +31,21 @@ export function apply(ctx: Context) { }), checkLock((...args) => require('./scripts/hustoj').run(...args)), ); + ctx.addScript( + 'migrateJnoj', 'migrate from jnoj', + Schema.object({ + host: Schema.string().default('localhost'), + port: Schema.number().default(3306), + name: Schema.string().default('jnoj'), + username: Schema.string().required(), + password: Schema.string().required(), + domainId: Schema.string().default('system'), + dataDir: Schema.string().default('/www/jnoj/jnoj/judge/data/'), + uploadDir: Schema.string().default('/www/jnoj/jnoj/web/uploads/'), + withContest: Schema.boolean().default(true), + }), + checkLock((...args) => require('./scripts/jnoj').run(...args)), + ); ctx.addScript( 'migrateSyzoj', 'migrate from syzoj', Schema.object({ @@ -105,6 +119,7 @@ export function apply(ctx: Context) { }); ctx.provideModule('hash', 'syzoj', (password: string) => md5(`${password}syzoj2_xxx`)); ctx.provideModule('hash', 'uoj', (password, salt, { uname }) => md5(`${uname}${crypto.createHmac('md5', salt).update(password).digest('hex')}`)); + ctx.provideModule('hash', 'jnoj', (password, salt) => compareSync(password, salt)); ctx.i18n.load('zh', { 'migrate from hustoj': '从 HustOJ 导入', diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 1c1457dcb..9a18a1f83 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -6,6 +6,7 @@ "author": "undefined ", "license": "AGPL-3.0-or-later", "dependencies": { + "bcryptjs": "^3.0.3", "mariadb": "^3.4.5", "mongodb": "^7.0.0", "turndown": "^7.2.2", diff --git a/packages/migrate/scripts/hustoj.ts b/packages/migrate/scripts/hustoj.ts index 1b66910c4..12643ab1d 100644 --- a/packages/migrate/scripts/hustoj.ts +++ b/packages/migrate/scripts/hustoj.ts @@ -297,7 +297,7 @@ hydrooj install https://hydro.ac/hydroac-client.zip description = description.replace(`/upload/${fileWithPath}`, `file://${filename}`); } // WHY you allow contest with end time BEFORE start time? WHY??? - const endAt = moment(tdoc.end_time).isSameOrBefore(tdoc.start_time) ? moment(tdoc.end_time).add(1, 'minute').toDate() : tdoc.end_time; + const endAt = moment(tdoc.end_time).isSameOrBefore(tdoc.start_time) ? moment(tdoc.start_time).add(1, 'minute').toDate() : tdoc.end_time; let isAssignMode = false; if (tdoc.private === 1 && tdoc.password === '') { isAssignMode = true; diff --git a/packages/migrate/scripts/jnoj.ts b/packages/migrate/scripts/jnoj.ts new file mode 100644 index 000000000..a8b5111ad --- /dev/null +++ b/packages/migrate/scripts/jnoj.ts @@ -0,0 +1,641 @@ +/* eslint-disable no-await-in-loop */ +import path from 'path'; +import mariadb from 'mariadb'; +import TurndownService from 'turndown'; +import { + _, buildContent, ContestModel, DiscussionModel, DocumentModel, DomainModel, fs, moment, noop, NotFoundError, ObjectId, postJudge, ProblemModel, + RecordDoc, RecordModel, SolutionModel, STATUS, StorageModel, SystemModel, Time, UserModel, +} from 'hydrooj'; + +const turndown = new TurndownService({ + codeBlockStyle: 'fenced', + bulletListMarker: '-', +}); + +function parsePhpArray(serialized: string) { + const initial = serialized; + const readString = (length: number) => { + const buf = new TextEncoder().encode(serialized).slice(0, length); + const result = new TextDecoder().decode(buf); + serialized = serialized.slice(result.length); + return result; + }; + const readInt = () => { + let val = 0; + if (!'0123456789'.includes(serialized[0])) throw new Error(`Expected number, got "${serialized[0]}", initial "${initial}"`); + while ('0123456789'.includes(serialized[0])) { + val = val * 10 + +serialized[0]; + serialized = serialized.slice(1); + } + return val; + }; + const readToken = (token: string) => { + if (serialized.startsWith(token)) serialized = serialized.slice(token.length); + else throw new Error(`Expected token "${token}", got "${serialized.slice(0, token.length)}", initial "${initial}"`); + }; + readToken('a:'); + const size = readInt(); + readToken(':{'); + const result = [...Array.from({ length: size }, () => '')]; + for (let i = 0; i < size; i++) { + readToken('i:'); + const key = readInt(); + readToken(';s:'); + const length = readInt(); + readToken(':"'); + const value = readString(length); + result[key] = value; + readToken('";'); + } + return result; +} + +async function addContestFile(domainId: string, tid: ObjectId, filename: string, filepath: string) { + const tdoc = await ContestModel.get(domainId, tid); + await StorageModel.put(`contest/${domainId}/${tid}/${filename}`, filepath, 1); + const meta = await StorageModel.getMeta(`contest/${domainId}/${tid}/${filename}`); + const payload = { _id: filename, name: filename, ..._.pick(meta, ['size', 'lastModified', 'etag']) }; + if (!meta) return false; + await ContestModel.edit(domainId, tid, { files: [...(tdoc.files || []), payload] }); + return true; +} + +function fixFileName(fileName: string) { + if (fileName.endsWith('.in') || fileName.endsWith('.out')) { + const dotAt = fileName.lastIndexOf('.'); + const name = fileName.slice(0, dotAt); + const suffix = fileName.slice(dotAt + 1); + if (!name.match(/[0-9]/)) { + fileName = `${name}0.${suffix}`; + } + } + return fileName.replace(/[\\/?#~!|*]/g, '_'); +} + +async function iterate( + count: bigint | number, step: bigint | number, cb: (pageId: bigint) => Promise, + reportOpts?: { every: number | bigint, namespace: string, report: (data: any) => void }, +) { + const _count = BigInt(count); + const _step = BigInt(step); + const { every, namespace, report } = reportOpts || {}; + const _showProgress = every ? BigInt(every) : 0n; + const pageCount = _count / _step + (_count % _step === 0n ? 0n : 1n); + for (let pageId = 0n; pageId < pageCount; pageId++) { + await cb(pageId); + if (reportOpts && pageId % _showProgress === 0n) { + const progress = pageId * _step * 100n / _count; + report({ + message: `${namespace} finished ${Number(pageId * _step)} / ${Number(count)} (${Number(progress)}%)`, + }); + } + } +} + +export async function run({ + host = 'localhost', port = 3306, name = 'jnoj', + username, password, domainId, dataDir = '/www/jnoj/jnoj/judge/data/', + uploadDir = '/www/jnoj/jnoj/web/uploads/', rerun = true, randomMail = false, + withContest = true, +}, report: (data: any) => void) { + const src = await mariadb.createConnection({ + host, + port, + user: username, + password, + database: name, + }); + const query = (q: string) => new Promise((res, rej) => { + src.query(q).then((r) => res(r)).catch((e) => rej(e)); + }); + report({ message: JSON.stringify(await query("show VARIABLES like 'char%';")) }); + const target = await DomainModel.get(domainId); + if (!target) throw new NotFoundError(domainId); + report({ message: 'Connected to database' }); + await SystemModel.set('migrate.lock', 'jnoj'); + /* User + +----------------------+--------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +----------------------+--------------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | username | varchar(64) | NO | UNI | NULL | | + | nickname | varchar(64) | NO | | NULL | | + | auth_key | varchar(32) | NO | | NULL | | + | password_hash | varchar(128) | NO | | NULL | | + | password_reset_token | varchar(255) | YES | | NULL | | + | email | varchar(255) | NO | UNI | NULL | | + | status | smallint | NO | | 10 | | + | role | smallint | NO | | 10 | | + | language | smallint | NO | | 1 | | + | created_at | datetime | NO | | NULL | | + | updated_at | datetime | YES | | NULL | | + | rating | int | YES | | NULL | | + | is_verify_email | smallint | NO | | 0 | | + | verification_token | varchar(255) | NO | | | | + +----------------------+--------------+------+-----+---------+----------------+ + User Profile + +----------------+--------------+------+-----+---------+-------+ + | Field | Type | Null | Key | Default | Extra | + +----------------+--------------+------+-----+---------+-------+ + | user_id | int | NO | PRI | NULL | | + | gender | smallint | YES | | 0 | | + | qq_number | bigint | YES | | NULL | | + | birthdate | date | YES | | NULL | | + | signature | varchar(255) | YES | | NULL | | + | address | varchar(255) | YES | | NULL | | + | description | varchar(255) | YES | | NULL | | + | school | varchar(255) | YES | | NULL | | + | student_number | varchar(64) | YES | | NULL | | + | major | varchar(64) | YES | | NULL | | + +----------------+--------------+------+-----+---------+-------+ + */ + const uidMap: Record = {}; + const udocs = await query('SELECT * FROM `user`'); + const precheck = await UserModel.getMulti({ unameLower: { $in: udocs.map((u) => u.username.toLowerCase()) } }).toArray(); + if (precheck.length) throw new Error(`Conflict username: ${precheck.map((u) => u.unameLower).join(', ')}`); + const originalRole = await DomainModel.getRoles(domainId); + if (!originalRole.find((i) => i._id === 'vip')) { + originalRole.push({ _id: 'vip', perm: originalRole.find((i) => i._id === 'default')?.perm || 0n }); + await DomainModel.setRoles(domainId, Object.fromEntries(originalRole.map((i) => [i._id, i.perm]))); + } + for (let uidx = 0; uidx < udocs.length; uidx += 1) { + const udoc = udocs[uidx]; + if (randomMail) delete udoc.email; + let current = await UserModel.getByEmail(domainId, udoc.email || `${udoc.username}@jnoj.local`); + current ||= await UserModel.getByUname(domainId, udoc.username); + if (current) { + report({ message: `duplicate user with email ${udoc.email}: ${current.uname},${udoc.username}` }); + uidMap[udoc.username] = current._id; + } else { + const uid = await UserModel.create( + udoc.email || `${udoc.username}@jnoj.local`, udoc.username, '', + null, '127.0.0.1', udoc.status === 10 ? SystemModel.get('default.priv') : 0, + ); + uidMap[udoc.username] = uid; + await UserModel.setById(uid, { + loginat: udoc.updated_at, + regat: udoc.created_at, + hash: udoc.password_hash, + salt: udoc.password_hash, + hashType: 'jnoj', + }); + await DomainModel.setUserInDomain(domainId, uid, { + nSubmit: 0, + nAccept: 0, + }); + if ([20, 30].includes(udoc.role)) { + await DomainModel.setUserRole(domainId, uid, udoc.role === 20 ? 'vip' : 'root'); + } + } + + if (uidx % 100 === 0) { + const progress = Math.round(((uidx + 1) / udocs.length) * 100); + report({ + message: `user finished ${uidx + 1} / ${udocs.length} (${progress}%)`, + }); + } + } + + /* + +--------------------+--------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +--------------------+--------------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | title | varchar(255) | YES | | NULL | | + | description | text | YES | | NULL | | + | input | text | YES | | NULL | | + | output | text | YES | | NULL | | + | sample_input | text | YES | | NULL | | + | sample_output | text | YES | | NULL | | + | spj | smallint | YES | | 0 | | + | hint | text | YES | | NULL | | + | source | varchar(255) | YES | | NULL | | + | time_limit | int | YES | | NULL | | + | memory_limit | int | YES | | NULL | | + | status | smallint | YES | | 0 | | + | accepted | int | YES | | 0 | | + | submit | int | YES | | 0 | | + | solved | int | YES | | 0 | | + | tags | text | YES | | NULL | | + | solution | text | YES | | NULL | | + | created_at | datetime | YES | | NULL | | + | created_by | int | YES | MUL | NULL | | + | updated_at | datetime | YES | | NULL | | + | polygon_problem_id | int | YES | | NULL | | + +--------------------+--------------+------+-----+---------+----------------+ + */ + const pidMap: Record = {}; + const [{ 'count(*)': pcount }] = await query('SELECT count(*) FROM `problem`'); + const step = 50n; + await iterate(pcount, 50n, async (pageId: bigint) => { + const pdocs = await query(`SELECT * FROM \`problem\` LIMIT ${Number(pageId * step)}, ${Number(step)}`); + for (const pdoc of pdocs) { + if (rerun) { + const opdoc = await ProblemModel.get(domainId, `P${pdoc.id}`); + if (opdoc) pidMap[pdoc.id] = opdoc.docId; + } + if (!pidMap[pdoc.id]) { + const files = {}; + const markdown = [pdoc.description, pdoc.input, pdoc.output, pdoc.hint].some((i) => i?.includes('[md]')); + const samples = []; + const sampleInput = parsePhpArray(pdoc.sample_input); + const sampleOutput = parsePhpArray(pdoc.sample_output); + if (sampleInput[0]?.trim() || sampleOutput[0]?.trim()) samples.push([sampleInput[0]?.trim(), sampleOutput[0]?.trim()]); + if (sampleInput[1]?.trim() || sampleOutput[1]?.trim()) samples.push([sampleInput[1]?.trim(), sampleOutput[1]?.trim()]); + if (sampleInput[2]?.trim() || sampleOutput[2]?.trim()) samples.push([sampleInput[2]?.trim(), sampleOutput[2]?.trim()]); + let content = buildContent({ + description: pdoc.description, + input: pdoc.input, + output: pdoc.output, + samples, + hint: pdoc.hint, + source: pdoc.source, + }, 'html').replace(/lns=/g, ' i.trim())?.filter((i) => i); + if (pdoc.source) tags.push(...pdoc.source.split(' ').map((i) => i.trim()).filter((i) => i)); + const pid = await ProblemModel.add( + domainId, `P${pdoc.id}`, pdoc.title, content, 1, tags, { hidden: pdoc.status === 1 }, + ); + if (!markdown) await ProblemModel.edit(domainId, pid, { html: true }); + pidMap[pdoc.id] = pid; + await Promise.all(Object.keys(files).map((filename) => ProblemModel.addAdditionalFile(domainId, pid, filename, files[filename]))); + if (Object.keys(files).length) report({ message: `move ${Object.keys(files).length} file for problem ${pid}` }); + } + await ProblemModel.edit(domainId, pidMap[pdoc.id], { + nAccept: pdoc.accepted, + nSubmit: pdoc.submit, + config: `time: ${pdoc.time_limit}s +memory: ${pdoc.memory_limit}m +`, + owner: uidMap[pdoc.created_by] || 1, + maintainer: [], + }); + if (pdoc.solution) { + const md = turndown.turndown(pdoc.solution); + await SolutionModel.add(domainId, pidMap[pdoc.id], uidMap[pdoc.created_by] || 1, md); + } + } + }, { every: 10n, namespace: 'problem', report }); + + report({ message: 'problem finished' }); + + /* Group + +-------------+--------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +-------------+--------------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | name | varchar(32) | NO | | NULL | | + | description | varchar(255) | NO | | | | + | status | tinyint | NO | | NULL | | + | join_policy | tinyint | NO | | 0 | | + | created_by | int | NO | | NULL | | + | created_at | datetime | NO | | NULL | | + | updated_at | datetime | NO | | NULL | | + +-------------+--------------+------+-----+---------+----------------+ + Group User + +------------+----------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +------------+----------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | group_id | int | NO | | NULL | | + | user_id | int | NO | | NULL | | + | role | tinyint | NO | | 0 | | + | created_at | datetime | NO | | NULL | | + +------------+----------+------+-----+---------+----------------+ + */ + + const groups = await query('SELECT * FROM `group`'); + const groupMap: Record = {}; + const groupMembers: Record = {}; + const escapeGroupName = (s: string) => { + let val = s.replace(/[_:/\\[\] %$^&!=();'".,<>?*@#-]/g, '_') || Math.random().toString(36).substring(2, 15); + while (Number.isSafeInteger(+val)) val = Math.random().toString(36).substring(2, 15); + return val; + }; + for (const group of groups) { + const n = escapeGroupName(group.name); + const members = await query(`SELECT * FROM \`group_user\` WHERE \`group_id\` = ${group.id} AND \`role\` > 3`); + groupMap[group.id] = n; + groupMembers[n] = members.map((i) => uidMap[i.user_id]); + await UserModel.updateGroup(domainId, n, groupMembers[n]); + } + + /* Contest + +-----------------+--------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +-----------------+--------------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | title | varchar(255) | YES | | NULL | | + | start_time | datetime | YES | | NULL | | + | end_time | datetime | YES | | NULL | | + | lock_board_time | datetime | YES | | NULL | | + | status | smallint | YES | | NULL | | + | editorial | text | YES | | NULL | | + | description | text | YES | | NULL | | + | type | smallint | NO | | 0 | | + | group_id | int | NO | | 0 | | + | scenario | smallint | NO | | 0 | | + | created_by | int | NO | MUL | NULL | | + +-----------------+--------------+------+-----+---------+----------------+ + Contest Problem + +------------+----------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +------------+----------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | problem_id | int | NO | MUL | NULL | | + | contest_id | int | NO | MUL | NULL | | + | num | smallint | NO | | NULL | | + +------------+----------+------+-----+---------+----------------+ + const TYPE_EDUCATIONAL = 0; + const TYPE_RANK_SINGLE = 1; + const TYPE_RANK_GROUP = 2; + const TYPE_HOMEWORK = 3; + const TYPE_OI = 4; + const TYPE_IOI = 5; + */ + const tidMap: Record = {}; + if (withContest) { + const typeMap = ['acm', 'acm', 'acm', 'homework', 'oi', 'ioi']; + const tdocs = await query('SELECT * FROM `contest`'); + for (let tidx = 0; tidx < tdocs.length; tidx += 1) { + const tdoc = tdocs[tidx]; + const pdocs = await query(`SELECT * FROM \`contest_problem\` WHERE \`contest_id\` = ${tdoc.id} ORDER BY \`num\` ASC`); + const pids = pdocs.map((i) => pidMap[i.problem_id]).filter((i) => i); + const files = {}; + let description = tdoc.description || ''; + const markdownUploadFiles = description.matchAll(/\(\/uploads(\/.+?)\)/g); + for (const file of markdownUploadFiles) { + const fileWithPath = file[1]; + const filename = fixFileName(path.basename(file[1])); + try { + files[filename] = await fs.readFile(path.join(uploadDir, fileWithPath)); + } catch (e) { + console.error('Failed to read file', fileWithPath, e); + } + description = description.replace(`/uploads${fileWithPath}`, `file://${filename}`); + } + tdoc.start_time ||= new Date(0); + const endAt = (!tdoc.end_time || moment(tdoc.end_time).isSameOrBefore(tdoc.start_time)) + ? moment(tdoc.start_time).add(1, 'minute').toDate() : tdoc.end_time; + const extra: Record = {}; + if (typeMap[tdoc.type] === 'homework') { + extra.penaltyRules = { 9999: 0 }; + extra.penaltySince = endAt; + } + if (tdoc.group_id) extra.assign = [groupMap[tdoc.group_id]]; + const tid = await ContestModel.add( + domainId, tdoc.title, description || 'Description', + uidMap[tdoc.created_by] || 1, typeMap[tdoc.type], tdoc.start_time, endAt, pids, true, + extra, + ); + tidMap[tdoc.id] = tid.toHexString(); + await Promise.all(Object.keys(files).map((filename) => addContestFile(domainId, tid, filename, files[filename]))); + if (Object.keys(files).length) report({ message: `move ${Object.keys(files).length} file for contest ${tidMap[tdoc.id]}` }); + if (tidx % 100 === 0) { + const progress = Math.round(((tidx + 1) / tdocs.length) * 100); + report({ + message: `contest finished ${tidx + 1} / ${tdocs.length} (${progress}%)`, + }); + } + } + report({ message: 'contest finished' }); + } + + /* + +------------+--------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +------------+--------------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | parent_id | int | YES | MUL | 0 | | + | title | varchar(255) | YES | | NULL | | + | created_by | int | NO | MUL | NULL | | + | content | text | YES | | NULL | | + | created_at | datetime | NO | | NULL | | + | updated_at | datetime | NO | | NULL | | + | status | smallint | NO | | 0 | | + | entity | varchar(32) | YES | MUL | NULL | | + | entity_id | int | YES | MUL | NULL | | + +------------+--------------+------+-----+---------+----------------+ + */ + const ddocs = await query('SELECT * FROM `discuss` WHERE `parent_id` = 0 AND `status` > 0'); + const discussRoots: Record = {}; + const discussMap: Record = {}; + for (const ddoc of ddocs) { + const _id = Time.getObjectID(ddoc.created_at, false); + const parentType = { + problem: DocumentModel.TYPE_PROBLEM, + contest: DocumentModel.TYPE_CONTEST, + news: DocumentModel.TYPE_DISCUSSION_NODE, + }[ddoc.entity]; + const parentId = ddoc.entity === 'news' ? 'News' + : ddoc.entity_id === 'problem' ? pidMap[ddoc.entity_id] : new ObjectId(tidMap[ddoc.entity_id]); + if (!parentId) continue; + const payload = { + domainId, + content: ddoc.content, + owner: uidMap[ddoc.created_by] || 1, + editor: uidMap[ddoc.created_by] || 1, + parentType, + parentId, + title: ddoc.title, + ip: '127.0.0.1', + nReply: 0, + highlight: false, + pin: false, + updateAt: ddoc.updated_at || ddoc.created_at, + views: 0, + sort: 100, + hidden: ddoc.status === 2, + docType: DocumentModel.TYPE_DISCUSSION, + docId: _id, + }; + await Promise.all([ + DocumentModel.coll.insertOne(payload), + DiscussionModel.coll.insertOne({ + domainId, docId: payload.docId, content: ddoc.content, uid: uidMap[ddoc.created_by] || 1, ip: '127.0.0.1', time: ddoc.created_at, + }), + ]); + discussRoots[ddoc.id] = payload.docId; + discussMap[ddoc.id] = payload.docId; + } + report({ message: 'discuss finished' }); + const drdocs = await query('SELECT * FROM `discuss` WHERE `parent_id` > 0 AND `status` > 0'); + for (const drdoc of drdocs) { + const drid = await DiscussionModel.addReply( + domainId, discussRoots[drdoc.parent_id], uidMap[drdoc.created_by] || 1, + `${drdoc.title}\n${drdoc.content}`, '127.0.0.1', + ); + discussRoots[drdoc.id] = drid; + } + + /* + +-------------+------------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +-------------+------------------+------+-----+---------+----------------+ + | id | int | NO | PRI | NULL | auto_increment | + | problem_id | int | NO | MUL | NULL | | + | time | int | NO | | 0 | | + | memory | int | NO | | 0 | | + | created_at | datetime | NO | | NULL | | + | source | text | NO | | NULL | | + | result | smallint | NO | MUL | 0 | | + | language | smallint | NO | | NULL | | + | contest_id | int | YES | MUL | NULL | | + | status | smallint | NO | | NULL | | + | code_length | int | NO | | NULL | | + | judgetime | datetime | YES | | NULL | | + | pass_info | varchar(255) | YES | | NULL | | + | score | tinyint unsigned | NO | | 0 | | + | judge | varchar(32) | YES | | NULL | | + | created_by | int | NO | MUL | NULL | | + +-------------+------------------+------+-----+---------+----------------+ + const OJ_WT0 = 0; + const OJ_WT1 = 1; + const OJ_CI = 2; + const OJ_RI = 3; + const OJ_AC = 4; + const OJ_PE = 5; + const OJ_WA = 6; + const OJ_TL = 7; + const OJ_ML = 8; + const OJ_OL = 9; + const OJ_RE = 10; + const OJ_CE = 11; + const OJ_SE = 12; + const OJ_NT = 13; + + const CLANG = 0; + const CPPLANG = 1; + const JAVALANG = 2; + const PYLANG = 3; + const PASCALLANG = 4; + Solution Info + +-------------+----------+------+-----+---------+-------+ + | Field | Type | Null | Key | Default | Extra | + +-------------+----------+------+-----+---------+-------+ + | solution_id | int | NO | PRI | NULL | | + | run_info | longtext | YES | | NULL | | + +-------------+----------+------+-----+---------+-------+ + */ + const statusMap = { + 4: STATUS.STATUS_ACCEPTED, + 5: STATUS.STATUS_WRONG_ANSWER, + 6: STATUS.STATUS_WRONG_ANSWER, + 7: STATUS.STATUS_TIME_LIMIT_EXCEEDED, + 8: STATUS.STATUS_MEMORY_LIMIT_EXCEEDED, + 9: STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED, + 10: STATUS.STATUS_RUNTIME_ERROR, + 11: STATUS.STATUS_COMPILE_ERROR, + 12: STATUS.STATUS_SYSTEM_ERROR, + 13: STATUS.STATUS_SYSTEM_ERROR, + }; + const langMap = { + 0: 'c', + 1: 'cc', + 2: 'java', + 3: 'py', + 4: 'pas', + }; + const buildTestCases = (runInfo: string) => { + try { + const info = JSON.parse(runInfo); + return { + testCases: info.subtasks.flatMap((subtask: any, subtaskId: number) => subtask.cases.map((cas: any, caseId: number) => ({ + subtaskId: subtaskId + 1, + id: caseId + 1, + status: statusMap[cas.verdict] || STATUS.STATUS_WAITING, + time: cas.time || 0, + memory: cas.memory || 0, + message: cas.checker_log + || (cas.exit_code ? `Exit code: ${cas.exit_code}` + : cas.checker_exit_code ? `Checker exit code: ${cas.checker_exit_code}` : ''), + }))), + }; + } catch (e) { + return { + compilerTexts: [runInfo], + }; + } + }; + + const [{ 'count(*)': rcount }] = await query('SELECT count(*) FROM `solution` WHERE `problem_id` > 0 AND `status` != 2'); + const attended = {}; + await iterate(rcount, 50n, async (pageId: bigint) => { + const rdocs = await query(`SELECT * FROM \`solution\` WHERE \`problem_id\` > 0 LIMIT ${pageId * BigInt(step)}, ${step}`); + const solInfos = await query(`SELECT * FROM \`solution_info\` WHERE \`solution_id\` IN (${rdocs.map((i) => i.id).join(',')})`); + const solInfoMap = _.keyBy(solInfos, 'solution_id'); + for (const rdoc of rdocs) { + const data: RecordDoc = { + status: statusMap[rdoc.result] || STATUS.STATUS_WAITING, + _id: Time.getObjectID(rdoc.created_at, false), + uid: uidMap[rdoc.created_by] || 0, + code: rdoc.source, + lang: langMap[rdoc.language] || '', + pid: pidMap[rdoc.problem_id] || 0, + domainId, + score: rdoc.score, + time: rdoc.time || 0, + memory: rdoc.memory || 0, + judgeTexts: [], + compilerTexts: [], + testCases: [], + judgeAt: new Date(), + rejudged: false, + judger: 1, + ...buildTestCases(solInfoMap[rdoc.id]?.run_info || '{}'), + }; + if (rdoc.contest_id && withContest) { + if (!tidMap[rdoc.contest_id]) { + report({ message: `warning: contest_id ${rdoc.contest_id} for submission ${rdoc.id} not found` }); + } else { + data.contest = new ObjectId(tidMap[rdoc.contest_id]); + if (!attended[`${data.contest}/${rdoc.created_by}`]) { + await ContestModel.attend(domainId, data.contest, uidMap[rdoc.created_by]).catch(noop); + attended[`${data.contest}/${rdoc.created_by}`] = true; + } + } + } + await RecordModel.coll.insertOne(data); + await postJudge(data).catch((err) => { + report({ message: err.message }); + console.log(err, data); + }); + } + }, { every: 10n, namespace: 'record', report }); + report({ message: 'record finished' }); + + src.end(); + + if (!dataDir) return true; + if (dataDir.endsWith('/')) dataDir = dataDir.slice(0, -1); + const files = await fs.readdir(dataDir, { withFileTypes: true }); + for (const file of files) { + if (!file.isDirectory()) continue; + const datas = await fs.readdir(`${dataDir}/${file.name}`, { withFileTypes: true }); + const pdoc = await ProblemModel.get(domainId, `P${file.name}`, undefined, true); + if (!pdoc) continue; + report({ message: `Syncing testdata for ${file.name}` }); + for (const data of datas) { + if (data.isDirectory()) continue; + const filename = fixFileName(data.name); + await ProblemModel.addTestdata(domainId, pdoc.docId, filename, `${dataDir}/${file.name}/${data.name}`); + } + await ProblemModel.addTestdata(domainId, pdoc.docId, 'config.yaml', Buffer.from(pdoc.config as string)); + } + await SystemModel.set('migrate.lock', 0); + return true; +} diff --git a/packages/onlyoffice/package.json b/packages/onlyoffice/package.json index 518c1d915..c50f65b3a 100644 --- a/packages/onlyoffice/package.json +++ b/packages/onlyoffice/package.json @@ -7,6 +7,6 @@ "preferUnplugged": true, "license": "AGPL-3.0-or-later", "dependencies": { - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.3" } } diff --git a/packages/scoreboard-xcpcio/package.json b/packages/scoreboard-xcpcio/package.json index bc52c5aaf..a64560527 100644 --- a/packages/scoreboard-xcpcio/package.json +++ b/packages/scoreboard-xcpcio/package.json @@ -8,9 +8,9 @@ "postinstall": "node -r @hydrooj/register install.ts" }, "dependencies": { - "@xcpcio/board-app": "^0.76.0" + "@xcpcio/board-app": "^0.76.1" }, "devDependencies": { - "@xcpcio/types": "^0.76.0" + "@xcpcio/types": "^0.76.1" } } diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 855b67365..3d56a343b 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -23,7 +23,7 @@ "@mantine/core": "^8.3.9", "@mantine/hooks": "^8.3.9", "@mantine/notifications": "^8.3.9", - "@sentry/browser": "^10.28.0", + "@sentry/browser": "^10.29.0", "@sentry/webpack-plugin": "^4.6.1", "@simplewebauthn/browser": "13.2.2", "@svgr/webpack": "^8.1.0", @@ -67,7 +67,7 @@ "jquery-scroll-lock": "^3.1.3", "jquery.easing": "^1.4.1", "jquery.transit": "^0.9.12", - "mantine-contextmenu": "^8.3.9", + "mantine-contextmenu": "^8.3.11", "matchmedia-polyfill": "^0.3.2", "md-editor-rt": "^6.2.0", "mini-css-extract-plugin": "^2.9.4", @@ -89,11 +89,11 @@ "prop-types": "^15.8.1", "qface": "^1.4.1", "qrcode": "^1.5.4", - "react": "^19.2.0", + "react": "^19.2.1", "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^19.2.0", + "react-dom": "^19.2.1", "react-redux": "^9.2.0", "reconnecting-websocket": "^4.4.0", "redux": "^5.0.1", diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 822d47188..4636a9684 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@vitejs/plugin-react": "^5.1.1", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "react": "^19.2.1", + "react-dom": "^19.2.1" }, "devDependencies": { "@types/react": "^19.2.7",