diff --git a/packages/hydrojudge/src/config.ts b/packages/hydrojudge/src/config.ts index 2e0aa6a36..38908fae1 100644 --- a/packages/hydrojudge/src/config.ts +++ b/packages/hydrojudge/src/config.ts @@ -6,24 +6,31 @@ import { fs, yaml } from '@hydrooj/utils'; const argv = cac().parse(); -const JudgeSettings = Schema.object({ - cache_dir: Schema.string().default(path.resolve(os.homedir(), '.cache', 'hydro', 'judge')), +const defaultEnv = `\ +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +HOME=/w +# modify to your python version installed +PYTHONPATH=/lib/python3.12/site-packages +`; + +export const JudgeSettings = Schema.object({ + cache_dir: Schema.string().default(path.resolve(os.homedir(), '.cache', 'hydro', 'judge')).description('Testdata cache directory'), tmp_dir: Schema.string().default(path.resolve(os.tmpdir(), 'hydro', 'judge')), stdio_size: Schema.string().pattern(/^\d+[kmg]b?$/g).default('32m'), memoryMax: Schema.string().pattern(/^\d+[kmg]b?$/g).default('512m'), - strict_memory: Schema.boolean().default(false), - sandbox_host: Schema.string().role('link').default('http://localhost:5050'), - testcases_max: Schema.number().default(100), - total_time_limit: Schema.number().default(60), - processLimit: Schema.number().default(32), - parallelism: Schema.number().default(2), + strict_memory: Schema.boolean().default(false).description('Use address space memory limit'), + sandbox_host: Schema.string().role('url').default('http://localhost:5050'), + testcases_max: Schema.number().default(100).min(1).step(1), + total_time_limit: Schema.number().default(60).min(1), + processLimit: Schema.number().default(32).min(1).step(1), + parallelism: Schema.number().default(2).min(1).step(1), concurrency: Schema.number(), - singleTaskParallelism: Schema.number().default(2), - rerun: Schema.number().description('Re-Run testcase if time-limit-exceeded (max per submission)').default(0), + singleTaskParallelism: Schema.number().default(2).min(1).step(1), + rerun: Schema.number().description('Re-Run testcase if time-limit-exceeded (max per submission)').default(0).min(0).step(1), rate: Schema.number().default(1), - env: Schema.string().default('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nHOME=/w'), + env: Schema.string().default(defaultEnv), host: Schema.any(), - secret: Schema.string().description('Judge Token Secret').default(String.random(32)), + secret: Schema.string().description('Judge Token Secret').default('Hydro-Judge-Secret'), disable: Schema.boolean().description('Disable builtin judge').default(false), detail: Schema.boolean().description('Show diff detail').default(true), performance: Schema.boolean().description('Performance mode').default(false), @@ -32,7 +39,7 @@ const JudgeSettings = Schema.object({ const oldPath = path.resolve(os.homedir(), '.config', 'hydro', 'judge.yaml'); const newPath = path.resolve(os.homedir(), '.hydro', 'judge.yaml'); -const config = global.Hydro +let config = global.Hydro ? JudgeSettings({}) : (() => { const base: any = {}; @@ -54,6 +61,8 @@ const config = global.Hydro return JudgeSettings(cfg); })(); -export const getConfig: (key: K) => typeof config[K] = global.Hydro - ? (key) => global.Hydro.model.system.get(`hydrojudge.${key}`) ?? config[key] - : (key) => config[key]; +export function overrideConfig(update: ReturnType) { + config = JudgeSettings(update); +} + +export const getConfig: (key: K) => typeof config[K] = (key) => config[key]; diff --git a/packages/hydrojudge/src/hosts/builtin.ts b/packages/hydrojudge/src/hosts/builtin.ts index 61e510a13..ff54fe0dc 100644 --- a/packages/hydrojudge/src/hosts/builtin.ts +++ b/packages/hydrojudge/src/hosts/builtin.ts @@ -4,7 +4,7 @@ import path from 'path'; import { fs } from '@hydrooj/utils'; import * as sysinfo from '@hydrooj/utils/lib/sysinfo'; import { - db, JudgeHandler, JudgeResultBody, ObjectId, RecordModel, + Context as HydroContext, db, JudgeHandler, JudgeResultBody, ObjectId, RecordModel, SettingModel, StorageModel, SystemModel, TaskModel, } from 'hydrooj'; import { langs } from 'hydrooj/src/model/setting'; @@ -59,10 +59,11 @@ const session: Session = { }, }; -export async function postInit(ctx) { - if (SystemModel.get('hydrojudge.disable')) return; +export async function* apply(ctx: HydroContext) { ctx.inject(['check'], (c) => { - c.check.addChecker('Judge', (_ctx, log, warn, error) => versionCheck(warn, error)); + c.check.addChecker('Judge', async (_ctx, log, warn, error) => { + await versionCheck(warn, error); + }); }); await fs.ensureDir(getConfig('tmp_dir')); const info = await sysinfo.get(); @@ -76,6 +77,7 @@ export async function postInit(ctx) { }; const parallelism = Math.max(getConfig('parallelism'), 2); const taskConsumer = TaskModel.consume({ type: 'judge' }, handle, true, parallelism); + yield () => taskConsumer.destroy(); async function collectInfo() { const coll = db.collection('status'); const compilers = await compilerVersions(langs); @@ -91,11 +93,11 @@ export async function postInit(ctx) { { upsert: true }, ); } - ctx.on('system/setting', () => { + yield ctx.on('system/setting', () => { taskConsumer.setConcurrency(Math.max(getConfig('parallelism'), 2)); collectInfo(); }); collectInfo(); - TaskModel.consume({ type: 'judge', priority: { $gt: -50 } }, handle); - TaskModel.consume({ type: 'generate' }, handle); + const generateConsumer = TaskModel.consume({ type: 'generate' }, handle); + yield () => generateConsumer.destroy(); } diff --git a/packages/hydrojudge/src/index.ts b/packages/hydrojudge/src/index.ts index 32f993322..853b396b1 100644 --- a/packages/hydrojudge/src/index.ts +++ b/packages/hydrojudge/src/index.ts @@ -1,20 +1,11 @@ import { Context } from 'hydrooj'; +import { JudgeSettings, overrideConfig } from './config'; -declare module 'hydrooj' { - interface SystemKeys { - 'hydrojudge.cache_dir': string; - 'hydrojudge.tmp_dir': string; - 'hydrojudge.sandbox_host': string; - 'hydrojudge.memoryMax': string; - 'hydrojudge.testcases_max': number; - 'hydrojudge.total_time_limit': number; - 'hydrojudge.parallelism': number; - 'hydrojudge.disable': boolean; - 'hydrojudge.detail': boolean; - } -} +export const Config = JudgeSettings; -export function apply(ctx: Context) { +export function apply(ctx: Context, config: ReturnType) { if (process.env.NODE_APP_INSTANCE !== '0') return; - ctx.once('app/started', () => require('./hosts/builtin').postInit(ctx)); + overrideConfig(config); + // eslint-disable-next-line consistent-return + if (!config.disable) return require('./hosts/builtin').apply(ctx); } diff --git a/packages/hydrooj/src/context.ts b/packages/hydrooj/src/context.ts index 8701456ca..5368fd0ef 100644 --- a/packages/hydrooj/src/context.ts +++ b/packages/hydrooj/src/context.ts @@ -6,7 +6,7 @@ import type { DomainDoc, GeoIP, ModuleInterfaces } from './interface'; import { inject } from './lib/ui'; import { Loader } from './loader'; import type { EventMap } from './service/bus'; -import type { CheckService } from './service/check'; +import type CheckService from './service/check'; import type { } from './service/migration'; // TODO: this is an broken declaration diff --git a/packages/hydrooj/src/handler/manage.ts b/packages/hydrooj/src/handler/manage.ts index 0b655e81e..0a70310bc 100644 --- a/packages/hydrooj/src/handler/manage.ts +++ b/packages/hydrooj/src/handler/manage.ts @@ -13,7 +13,6 @@ import record from '../model/record'; import * as setting from '../model/setting'; import * as system from '../model/system'; import user from '../model/user'; -import { check } from '../service/check'; import { ConnectionHandler, Handler, param, requireSudo, Types, } from '../service/server'; @@ -70,11 +69,11 @@ class SystemCheckConnHandler extends ConnectionHandler { const log = (payload: any) => this.send({ type: 'log', payload }); const warn = (payload: any) => this.send({ type: 'warn', payload }); const error = (payload: any) => this.send({ type: 'error', payload }); - await check.run(this, log, warn, error, (id) => { this.id = id; }); + await this.ctx.check.run(this, log, warn, error, (id) => { this.id = id; }); } async cleanup() { - check.cancel(this.id); + this.ctx.check.cancel(this.id); } } @@ -351,7 +350,7 @@ class SystemUserPrivHandler extends SystemHandler { } } -export const inject = ['config']; +export const inject = ['config', 'check']; export async function apply(ctx) { ctx.Route('manage', '/manage', SystemMainHandler); ctx.Route('manage_dashboard', '/manage/dashboard', SystemDashboardHandler); diff --git a/packages/hydrooj/src/service/check.ts b/packages/hydrooj/src/service/check.ts index cfa433c33..26bbdc198 100644 --- a/packages/hydrooj/src/service/check.ts +++ b/packages/hydrooj/src/service/check.ts @@ -7,11 +7,49 @@ import * as system from '../model/system'; import db from './db'; import storage from './storage'; -type CheckItem = (context: any, log: Function, warn: Function, error: Function) => Promise; +type ReportFunction = (message: string, ...args: any[]) => Promise; +type CheckItem = (context: any, log: ReportFunction, warn: ReportFunction, error: ReportFunction) => Promise; -export class CheckService extends Service { +export default class CheckService extends Service { checkers: Dictionary = {}; c = {}; + + constructor(ctx: Context) { + super(ctx, 'check'); + this.addChecker('Db', async (c, log, warn, error) => { + try { + const coll = db.collection('check'); + const d = await coll.findOne({ _id: 'check' }); + if (!d) await coll.insertOne({ _id: 'check', value: 'check' }); + await coll.createIndex('check'); + await coll.deleteOne({ _id: 'check' }); + await coll.drop(); + } catch (e) { + error(`Mongo Error: Database read-write failed.\n${e.message}`); + } + }); + this.addChecker('Storage', async (c, log, warn, error) => { + const status = await storage.status(); + if (!status.status) error(`Connect to ${status.type} Storage ${status.bucket} Error: ${status.error}`); + }); + this.addChecker('System', async (c, log, warn) => { + const platform = os.platform(); + if (platform !== 'linux') { + warn(`Hydro is running on ${platform}, suggest to use linux4.4+.`); + } + }); + this.addChecker('Mail', async (c, log, warn) => { + const from = system.get('smtp.from'); + if (!from) warn('SMTP account is not provided, email verification disabled.'); + }); + this.addChecker('Setting', async (c, log, warn) => { + const url = system.get('server.url'); + if (url === '/') warn("server.url isn't set."); + const header = system.get('server.xff'); + if (header && !c.request.ip) warn('IP header seems incorrect.\nCheck dashboard>settings>server.'); + }); + } + addChecker(type: string, checkFunc: CheckItem) { this.ctx.effect(() => { this.checkers[`check${type}`] = checkFunc; @@ -38,43 +76,3 @@ export class CheckService extends Service { this.c[id] = true; } } - -export const check = new CheckService(app, 'check'); - -check.addChecker('Db', async (ctx, log, warn, error) => { - try { - const coll = db.collection('check'); - const d = await coll.findOne({ _id: 'check' }); - if (!d) await coll.insertOne({ _id: 'check', value: 'check' }); - await coll.createIndex('check'); - await coll.deleteOne({ _id: 'check' }); - await coll.drop(); - } catch (e) { - error(`Mongo Error: Database read-write failed.\n${e.message}`); - } -}); -check.addChecker('Storage', async (ctx, log, warn, error) => { - const status = await storage.status(); - if (!status.status) error(`Connect to ${status.type} Storage ${status.bucket} Error: ${status.error}`); -}); -check.addChecker('System', async (ctx, log, warn) => { - const platform = os.platform(); - if (platform !== 'linux') { - warn(`Hydro is running on ${platform}, suggest to use linux4.4+.`); - } -}); -check.addChecker('Mail', async (ctx, log, warn) => { - const from = system.get('smtp.from'); - if (!from) warn('SMTP account is not provided, email verification disabled.'); -}); -check.addChecker('Setting', async (ctx, log, warn) => { - const url = system.get('server.url'); - if (url === '/') warn("server.url isn't set."); - const header = system.get('server.xff'); - if (header && !ctx.request.ip) warn('IP header seems incorrect.\nCheck dashboard>settings>server.'); -}); - -export async function apply(ctx: Context) { - ctx.provide('check', CheckService, true); - ctx.check = check; -} diff --git a/packages/login-with-github/index.ts b/packages/login-with-github/index.ts index a7cad1a01..106789f5a 100644 --- a/packages/login-with-github/index.ts +++ b/packages/login-with-github/index.ts @@ -1,82 +1,70 @@ import { - Context, ForbiddenError, Handler, superagent, SystemModel, + Context, ForbiddenError, Handler, Schema, Service, superagent, SystemModel, TokenModel, UserFacingError, ValidationError, } from 'hydrooj'; -declare module 'hydrooj' { - interface SystemKeys { - 'login-with-github.id': string; - 'login-with-github.secret': string; - 'login-with-github.endpoint': string; - } -} - -async function get(this: Handler) { - const [appid, [state]] = await Promise.all([ - SystemModel.get('login-with-github.id'), - TokenModel.add(TokenModel.TYPE_OAUTH, 600, { redirect: this.request.referer }), - ]); - this.response.redirect = `https://github.com/login/oauth/authorize?client_id=${appid}&state=${state}&scope=read:user,user:email`; -} - -async function callback({ state, code }) { - const [[appid, secret, endpoint, url], s] = await Promise.all([ - SystemModel.getMany([ - 'login-with-github.id', - 'login-with-github.secret', - 'login-with-github.endpoint', - 'server.url', - ]), - TokenModel.get(state, TokenModel.TYPE_OAUTH), - ]); - if (!s) throw new ValidationError('token'); - const res = await superagent.post(`${endpoint || 'https://github.com'}/login/oauth/access_token`) - .send({ - client_id: appid, - client_secret: secret, - code, - redirect_uri: `${url}oauth/github/callback`, - state, - }) - .set('accept', 'application/json'); - if (res.body.error) { - throw new UserFacingError( - res.body.error, res.body.error_description, res.body.error_uri, - ); - } - const t = res.body.access_token; - const userInfo = await superagent.get(`${endpoint ? `${endpoint}/api` : 'https://api.github.com'}/user`) - .set('User-Agent', 'Hydro-OAuth') - .set('Accept', 'application/vnd.github.v3+json') - .set('Authorization', `token ${t}`); - const ret = { - _id: `${userInfo.body.id}@github.local`, - email: userInfo.body.email, - bio: userInfo.body.bio, - uname: [userInfo.body.name, userInfo.body.login].filter((i) => i), - avatar: `github:${userInfo.body.login}`, - }; - if (!ret.email) { - const emailInfo = await superagent.get(`${endpoint ? `${endpoint}/api` : 'https://api.github.com'}/user/emails`) - .set('User-Agent', 'Hydro-OAuth') - .set('Accept', 'application/vnd.github.v3+json') - .set('Authorization', `token ${t}`); - if (emailInfo.body.length) { - ret.email = emailInfo.body.find((e) => e.primary && e.verified).email; - } - } - await TokenModel.del(s._id, TokenModel.TYPE_OAUTH); - if (!ret.email) throw new ForbiddenError("You don't have a verified email."); - return ret; -} - -export function apply(ctx: Context) { - ctx.provideModule('oauth', 'github', { - text: 'Login with Github', - callback, - get, - }); - ctx.i18n.load('zh', { - 'Login With Github': '使用 Github 登录', +export default class LoginWithGithubService extends Service { + static Config = Schema.object({ + id: Schema.string().description('Github OAuth AppID').required(), + secret: Schema.string().description('Github OAuth Secret').role('secret').required(), + endpoint: Schema.string().description('Github Endpoint'), }); + + constructor(ctx: Context, config: ReturnType) { + super(ctx, 'oauth.github'); + ctx.provideModule('oauth', 'github', { + text: 'Login with Github', + callback: async function callback({ state, code }) { + const url = await SystemModel.get('server.url'); + const s = await TokenModel.get(state, TokenModel.TYPE_OAUTH); + if (!s) throw new ValidationError('token'); + const res = await superagent.post(`${config.endpoint || 'https://github.com'}/login/oauth/access_token`) + .send({ + client_id: config.id, + client_secret: config.secret, + code, + redirect_uri: `${url}oauth/github/callback`, + state, + }) + .set('accept', 'application/json'); + if (res.body.error) { + throw new UserFacingError( + res.body.error, res.body.error_description, res.body.error_uri, + ); + } + const t = res.body.access_token; + const userInfo = await superagent.get(`${config.endpoint ? `${config.endpoint}/api` : 'https://api.github.com'}/user`) + .set('User-Agent', 'Hydro-OAuth') + .set('Accept', 'application/vnd.github.v3+json') + .set('Authorization', `token ${t}`); + const ret = { + _id: `${userInfo.body.id}@github.local`, + email: userInfo.body.email, + bio: userInfo.body.bio, + uname: [userInfo.body.name, userInfo.body.login].filter((i) => i), + avatar: `github:${userInfo.body.login}`, + }; + if (!ret.email) { + const emailInfo = await superagent.get(`${config.endpoint ? `${config.endpoint}/api` : 'https://api.github.com'}/user/emails`) + .set('User-Agent', 'Hydro-OAuth') + .set('Accept', 'application/vnd.github.v3+json') + .set('Authorization', `token ${t}`); + if (emailInfo.body.length) { + ret.email = emailInfo.body.find((e) => e.primary && e.verified).email; + } + } + await TokenModel.del(s._id, TokenModel.TYPE_OAUTH); + if (!ret.email) throw new ForbiddenError("You don't have a verified email."); + return ret; + }, + get: async function get(this: Handler) { + const [state] = await TokenModel.add(TokenModel.TYPE_OAUTH, 600, { redirect: this.request.referer }); + // eslint-disable-next-line max-len + this.response.redirect = `https://github.com/login/oauth/authorize?client_id=${config.appid}&state=${state}&scope=read:user,user:email`; + }, + }); + ctx.i18n.load('zh', { + 'Login With Github': '使用 Github 登录', + }); + } } diff --git a/packages/login-with-github/setting.yaml b/packages/login-with-github/setting.yaml deleted file mode 100644 index 6980123c8..000000000 --- a/packages/login-with-github/setting.yaml +++ /dev/null @@ -1,15 +0,0 @@ -id: - type: text - category: system - desc: Github OAuth AppID - name: id -secret: - type: text - category: system - desc: Github OAuth Secret - name: secret -endpoint: - type: text - category: system - desc: Github Api Endpoint - name: endpoint \ No newline at end of file diff --git a/packages/login-with-google/index.ts b/packages/login-with-google/index.ts index 23164886d..279c7404b 100644 --- a/packages/login-with-google/index.ts +++ b/packages/login-with-google/index.ts @@ -1,24 +1,7 @@ import { - Context, Handler, superagent, SystemModel, TokenModel, UserFacingError, + Context, Handler, Schema, Service, superagent, SystemModel, TokenModel, UserFacingError, } from 'hydrooj'; -declare module 'hydrooj' { - interface SystemKeys { - 'login-with-google.id': string, - 'login-with-google.secret': string, - } -} - -async function get(this: Handler) { - const [appid, url, [state]] = await Promise.all([ - SystemModel.get('login-with-google.id'), - SystemModel.get('server.url'), - TokenModel.add(TokenModel.TYPE_OAUTH, 600, { redirect: this.request.referer }), - ]); - // eslint-disable-next-line max-len - this.response.redirect = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${appid}&response_type=code&redirect_uri=${url}oauth/google/callback&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile&state=${state}`; -} - function unescapedString(escapedString: string) { escapedString += new Array(5 - (escapedString.length % 4)).join('='); return escapedString.replace(/-/g, '+').replace(/_/g, '/'); @@ -42,43 +25,57 @@ function decodeJWT(idToken: string) { } } -async function callback(this: Handler, { - state, code, error, -}) { - if (error) throw new UserFacingError(error); - const [[appid, secret, url], s] = await Promise.all([ - SystemModel.getMany([ - 'login-with-google.id', 'login-with-google.secret', 'server.url', - ]), - TokenModel.get(state, TokenModel.TYPE_OAUTH), - ]); - const res = await superagent.post('https://oauth2.googleapis.com/token') - .send({ - client_id: appid, - client_secret: secret, - code, - grant_type: 'authorization_code', - redirect_uri: `${url}oauth/google/callback`, - }); - const payload = decodeJWT(res.body.id_token).payload; - await TokenModel.del(state, TokenModel.TYPE_OAUTH); - this.response.redirect = s.redirect; - return { - // TODO use openid - _id: payload.email, - email: payload.email, - uname: [payload.given_name, payload.name, payload.family_name], - viewLang: payload.locale.replace('-', '_'), - }; -} +export default class GoogleOAuthService extends Service { + static Config = Schema.object({ + id: Schema.string().description('Google OAuth AppID').required(), + secret: Schema.string().description('Google OAuth Secret').role('secret').required(), + proxy: Schema.string().description('Google OAuth Proxy').role('proxy'), + }); -export function apply(ctx: Context) { - ctx.provideModule('oauth', 'google', { - text: 'Login with Google', - callback, - get, - }); - ctx.i18n.load('zh', { - 'Login With Google': '使用 Google 登录', - }); + constructor(ctx: Context, config: ReturnType) { + super(ctx, 'oauth.google'); + ctx.provideModule('oauth', 'google', { + text: 'Login with Google', + callback: async function callback(this: Handler, { + state, code, error, + }) { + if (error) throw new UserFacingError(error); + const [url, s] = await Promise.all([ + SystemModel.get('server.url'), + TokenModel.get(state, TokenModel.TYPE_OAUTH), + ]); + const res = await superagent.post('https://oauth2.googleapis.com/token') + .send({ + client_id: config.id, + client_secret: config.secret, + code, + grant_type: 'authorization_code', + redirect_uri: `${url}oauth/google/callback`, + }); + const payload = decodeJWT(res.body.id_token).payload; + await TokenModel.del(state, TokenModel.TYPE_OAUTH); + this.response.redirect = s.redirect; + return { + // TODO use openid + _id: payload.email, + email: payload.email, + uname: [payload.given_name, payload.name, payload.family_name], + viewLang: payload.locale.replace('-', '_'), + }; + }, + get: async function get(this: Handler) { + const [appid, url, [state]] = await Promise.all([ + config.id, + config.proxy, + TokenModel.add(TokenModel.TYPE_OAUTH, 600, { redirect: this.request.referer }), + ]); + const scope = encodeURIComponent('https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile'); + // eslint-disable-next-line max-len + this.response.redirect = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${appid}&response_type=code&redirect_uri=${url}oauth/google/callback&scope=${scope}&state=${state}`; + }, + }); + ctx.i18n.load('zh', { + 'Login With Google': '使用 Google 登录', + }); + } } diff --git a/packages/login-with-google/setting.yaml b/packages/login-with-google/setting.yaml deleted file mode 100644 index 02b865644..000000000 --- a/packages/login-with-google/setting.yaml +++ /dev/null @@ -1,15 +0,0 @@ -id: - type: text - category: system - desc: Google OAuth AppID - name: id -secret: - type: text - category: system - desc: Google OAuth Secret - name: secret -proxy: - type: text - category: system - desc: Google OAuth Proxy - name: proxy \ No newline at end of file diff --git a/packages/onlyoffice/index.ts b/packages/onlyoffice/index.ts index 46b614154..5eb2b937a 100644 --- a/packages/onlyoffice/index.ts +++ b/packages/onlyoffice/index.ts @@ -1,7 +1,7 @@ import { sign } from 'jsonwebtoken'; import { } from '@hydrooj/ui-default/backendlib/markdown-it-media'; import { - Context, Handler, superagent, SystemModel, UiContextBase, ValidationError, + Context, Handler, Schema, superagent, SystemModel, UiContextBase, ValidationError, } from 'hydrooj'; declare module 'hydrooj' { @@ -72,19 +72,6 @@ class OnlyofficeJWTHandler extends Handler { } export function apply(ctx: Context) { - Object.defineProperty(UiContextBase, 'onlyofficeApi', { - configurable: true, - enumerable: true, - get() { - return SystemModel.get('onlyoffice.api'); - }, - }); - ctx.on('dispose', () => { - Object.defineProperty(UiContextBase, 'onlyofficeApi', { - enumerable: false, - }); - }); - ctx.inject(['i18n'], (c) => { c.i18n.load('en', { 'onlyoffice.not_configured': 'Onlyoffice API not configured.', @@ -104,4 +91,24 @@ export function apply(ctx: Context) { }, }); } + ctx.setting.SystemSetting(Schema.object({ + onlyoffice: Schema.object({ + api: Schema.string().description('OnlyOffice API URL').role('url').default('https://documentserver/web-apps/apps/api/documents/api.js'), + jwtsecret: Schema.string().description('JWT Secret').default('secret'), + pdf: Schema.boolean().description('Handle pdf documents').default(false), + externalSign: Schema.string().description('External Sign URL').default(''), + }), + })); + Object.defineProperty(UiContextBase, 'onlyofficeApi', { + configurable: true, + enumerable: true, + get() { + return SystemModel.get('onlyoffice.api'); + }, + }); + return () => { + Object.defineProperty(UiContextBase, 'onlyofficeApi', { + enumerable: false, + }); + }; } diff --git a/packages/onlyoffice/setting.yaml b/packages/onlyoffice/setting.yaml deleted file mode 100644 index fad1a57a3..000000000 --- a/packages/onlyoffice/setting.yaml +++ /dev/null @@ -1,20 +0,0 @@ -api: - type: text - name: api - desc: OnlyOffice API URL - default: https://documentserver/web-apps/apps/api/documents/api.js -jwtsecret: - type: text - name: jwtsecret - desc: JWT Secret - default: secret -pdf: - type: boolean - name: pdf - desc: Handle pdf documents - default: false -externalSign: - type: text - name: externalSign - desc: External Sign URL - default: '' diff --git a/packages/scoreboard-xcpcio/index.ts b/packages/scoreboard-xcpcio/index.ts index 762ee9836..aa3621120 100644 --- a/packages/scoreboard-xcpcio/index.ts +++ b/packages/scoreboard-xcpcio/index.ts @@ -100,7 +100,7 @@ export async function apply(ctx: Context) { }, }, submissions: tsdocs.flatMap((i) => (i.journal || []).map((j) => { - const submit = new ObjectId(j.rid).getTimestamp().getTime(); + const submit = new ObjectId(j.rid as string).getTimestamp().getTime(); const curStatus = status[j.status] || 'SYSTEM_ERROR'; return { problem_id: tdoc.pids.indexOf(j.pid), diff --git a/packages/sonic/index.ts b/packages/sonic/index.ts index 2435cdaa3..71867e52b 100644 --- a/packages/sonic/index.ts +++ b/packages/sonic/index.ts @@ -1,91 +1,166 @@ +import { + Ingest, PushOptions, QueryOptions, + Search, SuggestOptions, +} from 'sonic-channel'; import { Context, iterateAllProblem, iterateAllProblemInDomain, - Logger, ProblemModel, Schema, SystemModel, + Logger, ProblemModel, Schema, Service, SystemModel, } from 'hydrooj'; -import { SonicService } from './service'; + +declare module 'hydrooj' { + interface Context { + sonic?: SonicService; + } +} const logger = new Logger('sonic'); -export const inject = { 'sonic': { required: false } }; -export function apply(ctx: Context) { - ctx.plugin(SonicService); +function getHandler(type: string, that: any) { + return { + connected() { + logger.info(`Sonic Channel succeeded to connect to host (${type}).`); + }, + disconnected() { + logger.error(`Sonic Channel is now disconnected (${type}).`); + }, + timeout() { + logger.error(`Sonic Channel connection timed out (${type}).`); + }, + retrying() { + logger.error(`Trying to reconnect to Sonic Channel (${type})...`); + }, + error(error) { + that.error = error; + logger.error(`Sonic Channel failed to connect to host (${type}).`, error); + }, + }; +} - ctx.provideModule('problemSearch', 'sonic', async (domainId, query, opts) => { - const limit = opts?.limit || SystemModel.get('pagination.problem'); - let hits = await ctx.sonic.query('problem', `${domainId}@title`, query, { limit }); - if (!opts.skip) { - let pdoc = await ProblemModel.get(domainId, +query || query, ProblemModel.PROJECTION_LIST); - if (pdoc) { - hits = hits.filter((i) => i !== `${pdoc.domainId}/${pdoc.docId}`); - hits.unshift(`${pdoc.domainId}/${pdoc.docId}`); - } else if (/^P\d+$/.test(query)) { - pdoc = await ProblemModel.get(domainId, +query.substring(1), ProblemModel.PROJECTION_LIST); - if (pdoc) hits.unshift(`${pdoc.domainId}/${pdoc.docId}`); - } - } - if (limit - hits.length > 0) hits.push(...await ctx.sonic.query('problem', `${domainId}@content`, query, { limit: limit - hits.length })); - return { - countRelation: hits.length >= limit ? 'gte' : 'eq', - total: hits.length, - hits, - }; +export class SonicService extends Service { + public search: Search; + public ingest: Ingest; + public error = ''; + + static Config = Schema.object({ + host: Schema.string().default('::1'), + port: Schema.number().default(1491), + auth: Schema.string().default(''), }); - async function run({ domainId }, report) { - if (domainId) await ctx.sonic.flushb('problem', domainId); - else await ctx.sonic.flushc('problem'); - let i = 0; - const cb = async (pdoc) => { - i++; - if (!(i % 1000)) report({ message: `${i} problems indexed` }); - await Promise.all([ - pdoc.title && ctx.sonic.push( - 'problem', `${pdoc.domainid}@title`, `${pdoc.domainId}/${pdoc.docId}`, - `${pdoc.pid || ''} ${pdoc.title} ${pdoc.tag.join(' ')}`, - ), - pdoc.content.toString() - && ctx.sonic.push('problem', `${pdoc.domainId}@content`, `${pdoc.domainId}/${pdoc.docId}`, pdoc.content.toString()), - ]).catch((e) => console.log(`${pdoc.domainId}/${pdoc.docId}`, e)); - }; - if (domainId) await iterateAllProblemInDomain(domainId, ['title', 'content'], cb); - else await iterateAllProblem(['title', 'content', 'tag'], cb); - return true; + constructor(ctx: Context, private config: ReturnType) { + super(ctx, 'sonic'); } - ctx.on('problem/add', async (doc, docId) => { - Promise.all([ - ctx.sonic.push('problem', `${doc.domainId}@title`, `${doc.domainId}/${docId}`, `${doc.pid || ''} ${doc.title} ${doc.tag?.join(' ')}`), - ctx.sonic.push('problem', `${doc.domainId}@content`, `${doc.domainId}/${docId}`, doc.content.toString()), - ]).catch((e) => logger.error(e)); - }); + *[Context.init]() { + this.search = new Search(this.config); + this.ingest = new Ingest(this.config); + this.search.connect(getHandler('search', this)); + yield () => this.search.close(); + this.ingest.connect(getHandler('ingest', this)); + yield () => this.ingest.close(); + yield this.ctx.on('problem/add', async (doc, docId) => { + Promise.all([ + this.push('problem', `${doc.domainId}@title`, `${doc.domainId}/${docId}`, `${doc.pid || ''} ${doc.title} ${doc.tag?.join(' ')}`), + this.push('problem', `${doc.domainId}@content`, `${doc.domainId}/${docId}`, doc.content.toString()), + ]).catch((e) => logger.error(e)); + }); + yield this.ctx.on('problem/edit', async (pdoc) => { + const id = `${pdoc.domainId}/${pdoc.docId}`; + Promise.all([ + this.flusho('problem', `${pdoc.domainId}@title`, id) + .then(() => this.push('problem', `${pdoc.domainId}@title`, id, `${pdoc.pid || ''} ${pdoc.title} ${pdoc.tag?.join(' ')}`)), + this.flusho('problem', `${pdoc.domainId}@content`, id) + .then(() => this.push('problem', `${pdoc.domainId}@content`, id, pdoc.content.toString())), + ]).catch((e) => logger.error(e)); + }); + yield this.ctx.on('problem/del', async (domainId, docId) => { + const id = `${domainId}/${docId}`; + await Promise.all([ + this.flusho('problem', `${domainId}@title`, id), + this.flusho('problem', `${domainId}@content`, id), + ]); + }); + yield this.ctx.provideModule('problemSearch', 'sonic', async (domainId, query, opts) => { + const limit = opts?.limit || SystemModel.get('pagination.problem'); + let hits = await this.query('problem', `${domainId}@title`, query, { limit }); + if (!opts.skip) { + let pdoc = await ProblemModel.get(domainId, +query || query, ProblemModel.PROJECTION_LIST); + if (pdoc) { + hits = hits.filter((i) => i !== `${pdoc.domainId}/${pdoc.docId}`); + hits.unshift(`${pdoc.domainId}/${pdoc.docId}`); + } else if (/^P\d+$/.test(query)) { + pdoc = await ProblemModel.get(domainId, +query.substring(1), ProblemModel.PROJECTION_LIST); + if (pdoc) hits.unshift(`${pdoc.domainId}/${pdoc.docId}`); + } + } + if (limit - hits.length > 0) hits.push(...await this.query('problem', `${domainId}@content`, query, { limit: limit - hits.length })); + return { + countRelation: hits.length >= limit ? 'gte' : 'eq', + total: hits.length, + hits, + }; + }); + yield this.ctx.addScript( + 'ensureSonicSearch', 'Sonic problem search re-index', + Schema.object({ + domainId: Schema.string(), + }), + async ({ domainId }, report) => { + if (domainId) await this.flushb('problem', domainId); + else await this.flushc('problem'); + let i = 0; + const cb = async (pdoc) => { + i++; + if (!(i % 1000)) report({ message: `${i} problems indexed` }); + await Promise.all([ + pdoc.title && this.push( + 'problem', `${pdoc.domainid}@title`, `${pdoc.domainId}/${pdoc.docId}`, + `${pdoc.pid || ''} ${pdoc.title} ${pdoc.tag.join(' ')}`, + ), + pdoc.content.toString() + && this.push('problem', `${pdoc.domainId}@content`, `${pdoc.domainId}/${pdoc.docId}`, pdoc.content.toString()), + ]).catch((e) => console.log(`${pdoc.domainId}/${pdoc.docId}`, e)); + }; + if (domainId) await iterateAllProblemInDomain(domainId, ['title', 'content'], cb); + else await iterateAllProblem(['title', 'content', 'tag'], cb); + return true; + }, + ); - ctx.on('problem/edit', async (pdoc) => { - const id = `${pdoc.domainId}/${pdoc.docId}`; - Promise.all([ - ctx.sonic.flusho('problem', `${pdoc.domainId}@title`, id) - .then(() => ctx.sonic.push('problem', `${pdoc.domainId}@title`, id, `${pdoc.pid || ''} ${pdoc.title} ${pdoc.tag?.join(' ')}`)), - ctx.sonic.flusho('problem', `${pdoc.domainId}@content`, id) - .then(() => ctx.sonic.push('problem', `${pdoc.domainId}@content`, id, pdoc.content.toString())), - ]).catch((e) => logger.error(e)); - }); + this.ctx.i18n.load('zh', { + 'Sonic problem search re-index': '重建题目搜索索引。', + }); + } - ctx.on('problem/del', async (domainId, docId) => { - const id = `${domainId}/${docId}`; - await Promise.all([ - ctx.sonic.flusho('problem', `${domainId}@title`, id), - ctx.sonic.flusho('problem', `${domainId}@content`, id), - ]); - }); + async query(collection: string, bucket: string, terms: string, options?: QueryOptions) { + return await this.search.query(collection, bucket, terms, options); + } - ctx.addScript( - 'ensureSonicSearch', 'Sonic problem search re-index', - Schema.object({ - domainId: Schema.string(), - }), - run, - ); + async suggest(collection: string, bucket: string, word: string, options?: SuggestOptions) { + return await this.search.suggest(collection, bucket, word, options); + } - ctx.i18n.load('zh', { - 'Sonic problem search re-index': '重建题目搜索索引。', - }); + async push(collection: string, bucket: string, object: string, text: string, options?: PushOptions) { + return await this.ingest.push(collection, bucket, object, text, options); + } + + async pop(collection: string, bucket: string, object: string, text: string) { + return await this.ingest.pop(collection, bucket, object, text); + } + + async count(collection: string, bucket?: string, object?: string) { + return await this.ingest.count(collection, bucket, object); + } + + async flusho(collection: string, bucket: string, object: string) { + return await this.ingest.flusho(collection, bucket, object); + } + + async flushb(collection: string, bucket: string) { + return await this.ingest.flushb(collection, bucket); + } + + async flushc(collection: string) { + return await this.ingest.flushc(collection); + } } diff --git a/packages/sonic/service.ts b/packages/sonic/service.ts deleted file mode 100644 index 08e0ba802..000000000 --- a/packages/sonic/service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - Ingest, PushOptions, QueryOptions, - Search, SuggestOptions, -} from 'sonic-channel'; -import { - Context, Logger, Service, SystemModel, -} from 'hydrooj'; - -declare module 'hydrooj' { - interface SystemKeys { - 'sonic.host': string; - 'sonic.port': number; - 'sonic.auth': string; - } - interface Context { - sonic?: SonicService; - } -} - -const logger = new Logger('sonic'); - -function getHandler(type: string, that: any) { - return { - connected() { - logger.info(`Sonic Channel succeeded to connect to host (${type}).`); - }, - disconnected() { - logger.error(`Sonic Channel is now disconnected (${type}).`); - }, - timeout() { - logger.error(`Sonic Channel connection timed out (${type}).`); - }, - retrying() { - logger.error(`Trying to reconnect to Sonic Channel (${type})...`); - }, - error(error) { - that.error = error; - logger.error(`Sonic Channel failed to connect to host (${type}).`, error); - }, - }; -} - -export class SonicService extends Service { - public search: Search; - public ingest: Ingest; - public error = ''; - - constructor(ctx: Context) { - super(ctx, 'sonic'); - } - - async start() { - const [host, port, auth] = SystemModel.getMany(['sonic.host', 'sonic.port', 'sonic.auth']); - const cfg = { - host: host || '::1', - port: port || 1491, - auth: auth || '', - }; - this.search = new Search(cfg); - this.ingest = new Ingest(cfg); - try { - this.search.connect(getHandler('search', this)); - this.ingest.connect(getHandler('ingest', this)); - } catch (e) { - logger.warn('Sonic init fail. will retry later.'); - this.error = e.toString(); - setTimeout(() => this.start(), 10000); - } - } - - async query(collection: string, bucket: string, terms: string, options?: QueryOptions) { - return await this.search.query(collection, bucket, terms, options); - } - - async suggest(collection: string, bucket: string, word: string, options?: SuggestOptions) { - return await this.search.suggest(collection, bucket, word, options); - } - - async push(collection: string, bucket: string, object: string, text: string, options?: PushOptions) { - return await this.ingest.push(collection, bucket, object, text, options); - } - - async pop(collection: string, bucket: string, object: string, text: string) { - return await this.ingest.pop(collection, bucket, object, text); - } - - async count(collection: string, bucket?: string, object?: string) { - return await this.ingest.count(collection, bucket, object); - } - - async flusho(collection: string, bucket: string, object: string) { - return await this.ingest.flusho(collection, bucket, object); - } - - async flushb(collection: string, bucket: string) { - return await this.ingest.flushb(collection, bucket); - } - - async flushc(collection: string) { - return await this.ingest.flushc(collection); - } - - async stop() { - await Promise.all([ - this.search.close(), - this.ingest.close(), - ]); - } -} diff --git a/packages/sonic/setting.yaml b/packages/sonic/setting.yaml deleted file mode 100644 index f74a1ff8a..000000000 --- a/packages/sonic/setting.yaml +++ /dev/null @@ -1,8 +0,0 @@ -host: - value: ::1 -port: - type: number - value: 1491 -auth: - type: text - value: SecretPassword