From cfdf9fe7a45e58d20c56b5f8c75e38bf17cdf736 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 24 Jan 2022 19:31:03 +0800 Subject: [PATCH] core&ui: feat: enhanced system settings (beta) --- packages/hydrooj/package.json | 3 +- packages/hydrooj/src/handler/manage.ts | 55 +++++++ packages/hydrooj/src/settings.ts | 136 ++++++++++++++++++ packages/hydrooj/src/typeutils.ts | 1 + .../components/editor/cmeditor.page.ts | 5 +- .../components/editor/cmeditor.styl | 4 + .../ui-default/components/editor/index.ts | 13 +- .../components/monaco/languages/yaml.ts | 4 + packages/ui-default/package.json | 4 +- 9 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 packages/hydrooj/src/settings.ts diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 231167c32..0992e7bf8 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.3.15", + "version": "3.3.16", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", @@ -15,6 +15,7 @@ "@graphql-tools/schema": "^8.3.1", "@hydrooj/utils": "workspace:*", "adm-zip": "0.5.5", + "ajv": "^8.9.0", "ansi_up": "^5.1.0", "cac": "^6.7.12", "cookies": "^0.8.0", diff --git a/packages/hydrooj/src/handler/manage.ts b/packages/hydrooj/src/handler/manage.ts index ee3dff8f8..6a9c2a9c9 100644 --- a/packages/hydrooj/src/handler/manage.ts +++ b/packages/hydrooj/src/handler/manage.ts @@ -1,4 +1,5 @@ import { inspect } from 'util'; +import Ajv from 'ajv'; import * as yaml from 'js-yaml'; import * as check from '../check'; import { ValidationError } from '../error'; @@ -17,9 +18,12 @@ import { Connection, ConnectionHandler, Handler, param, Route, Types, } from '../service/server'; +import { schema } from '../settings'; import * as judge from './judge'; const logger = new Logger('manage'); +const ajv = new Ajv({ useDefaults: true }); +const validator = ajv.compile(schema); function set(key: string, value: any) { if (setting.SYSTEM_SETTINGS_BY_KEY[key]) { @@ -140,12 +144,55 @@ class SystemSettingHandler extends SystemHandler { for (const s of this.response.body.settings) { this.response.body.current[s.key] = system.get(s.key); } + const hide = []; + const raw = system.get('_') || ''; + let config = yaml.load(raw); + const valid = validator(config); + if (!(raw?.trim() && valid)) { + const data = {}; + for (const key in schema.properties) { + data[key] = {}; + for (const subkey in (schema.definitions[key] as any).properties || {}) { + if (system.get(`${key}.${subkey}`)) { + data[key][subkey] = system.get(`${key}.${subkey}`); + if ((schema.definitions[key] as any).properties[subkey]?.writeOnly) { + if (data[key][subkey] instanceof Array) { + hide.push(...data[key][subkey]); + } else hide.push(data[key][subkey]); + } + } + } + } + validator(data); + config = yaml.dump(data); + } else config = yaml.dump(config); + this.response.body.hide = hide; + this.response.body.config = config; } async post(args: any) { const tasks = []; const booleanKeys = args.booleanKeys || {}; delete args.booleanKeys; + if (args._) { + if (typeof args._ !== 'string') throw new ValidationError('config'); + try { + const payload = yaml.load(args._); + const valid = validator(payload); + if (!valid) { + throw new ValidationError('config', null, validator.errors[0]); + } + for (const key in payload) { + for (const subkey in payload[key]) { + tasks.push(system.set(`${key}.${subkey}`, payload[key][subkey])); + } + } + } catch (e) { + throw new ValidationError('config', null, e.message); + } + await system.set('_', args._); + delete args._; + } for (const key in args) { if (typeof args[key] === 'object') { for (const subkey in args[key]) { @@ -169,6 +216,13 @@ class SystemSettingHandler extends SystemHandler { } } +class SystemSettingSchemaHandler extends SystemHandler { + async get() { + this.response.body = JSON.stringify(schema); + this.response.type = 'application/json'; + } +} + class SystemUserImportHandler extends SystemHandler { async get() { this.response.body.users = []; @@ -228,6 +282,7 @@ async function apply() { Route('manage_dashboard', '/manage/dashboard', SystemDashboardHandler); Route('manage_script', '/manage/script', SystemScriptHandler); Route('manage_setting', '/manage/setting', SystemSettingHandler); + Route('manage_setting_schema', '/manage/setting/schema.json', SystemSettingSchemaHandler); Route('manage_user_import', '/manage/userimport', SystemUserImportHandler); Connection('manage_check', '/manage/check-conn', SystemCheckConnHandler); } diff --git a/packages/hydrooj/src/settings.ts b/packages/hydrooj/src/settings.ts new file mode 100644 index 000000000..602a4eb14 --- /dev/null +++ b/packages/hydrooj/src/settings.ts @@ -0,0 +1,136 @@ +import { JSONSchema7Definition } from 'json-schema'; + +type Def = Exclude; + +function port(examples: number[] = []) { + const res: Def = { + type: 'integer', minimum: 1, maximum: 65535, + }; + if (examples.length) { + res.default = examples[0]; + res.examples = examples; + } + return res; +} + +export const Schema = { + string(title: string, defaultValue: string, extra?: T) { + return { + type: 'string' as 'string', + default: defaultValue, + title, + ...extra, + }; + }, + boolean(title: string, defaultValue: boolean, extra?: T) { + return { + type: 'boolean' as 'boolean', + default: defaultValue, + title, + ...extra, + }; + }, + integer(title: string, defaultValue: number, extra?: T) { + return { + type: 'integer' as 'integer', + default: defaultValue, + title, + ...extra, + }; + }, +}; + +const definitions: Record = { + smtp: { + type: 'object', + properties: { + user: Schema.string('SMTP Username', 'noreply@hydro.ac'), + from: Schema.string('Mail From', 'Hydro '), + pass: Schema.string('SMTP Password', '', { writeOnly: true }), + host: Schema.string('SMTP Server Host', 'smtp.hydro.ac', { pattern: '^[a-zA-Z0-9\\-\\.]+$' }), + port: Schema.integer('SMTP Server Port', 25, { examples: [25, 465], minimum: 1, maximum: 65535 }), + secure: Schema.boolean('Use SSL', false), + verify: Schema.boolean('Verify register email', false), + }, + additionalProperties: false, + }, + file: { + type: 'object', + properties: { + endPoint: Schema.string('Storage engine endPoint', 'http://localhost:9000', { + pattern: '^https?://[a-zA-Z0-9\\-\\.]+/?$', + }), + accessKey: Schema.string('Storage engine accessKey', ''), + secretKey: Schema.string('Storage engine secretKey', '', { writeOnly: true }), + bucket: Schema.string('Storage engine bucket', 'hydro'), + region: Schema.string('Storage engine region', 'us-east-1'), + pathStyle: Schema.boolean('pathStyle endpoint', true), + endPointForUser: Schema.string('EndPoint for user', '/fs/'), + endPointForJudge: Schema.string('EndPoint for judge', '/fs/'), + }, + required: ['endPoint', 'accessKey', 'secretKey'], + additionalProperties: false, + }, + server: { + type: 'object', + properties: { + name: Schema.string('Server Name', 'Hydro'), + url: Schema.string('Self URL', 'https://hydro.ac/', { pattern: '/$' }), + cdn: Schema.string('CDN prefix', '/', { + pattern: '/$', examples: ['/', 'https://cdn.hydro.ac/'], + }), + port: port([8888, 80, 443]), + xff: Schema.string('IP Header', '', { examples: ['x-forwarded-for', 'x-real-ip'], pattern: '^[a-z-]+$' }), + xhost: Schema.string('Host Header', '', { examples: ['x-real-host'], pattern: '^[a-z-]+$' }), + language: { type: 'string', enum: Object.keys(global.Hydro.locales) }, + upload: Schema.string('Upload size limit', '256m', { pattern: '^[0-9]+[mkg]b?$' }), + login: Schema.boolean('Enable builtin login', true), + message: Schema.boolean('Enable message', true), + blog: Schema.boolean('Enable blog', true), + checkUpdate: Schema.boolean('Daily update check', true), + }, + required: ['url', 'port', 'language'], + }, + limit: { + type: 'object', + properties: { + problem_files_max: { type: 'integer', minimum: 0 }, + }, + }, + session: { + type: 'object', + properties: { + keys: { + type: 'array', items: { type: 'string' }, default: [String.random(32)], writeOnly: true, + }, + secure: { type: 'boolean', default: false }, + saved_expire_seconds: { type: 'integer', minimum: 300, default: 3600 * 24 * 30 }, + unsaved_expire_seconds: { type: 'integer', minimum: 60, default: 3600 * 3 }, + }, + }, + user: { + type: 'object', + properties: { + quota: { type: 'integer', minimum: 0 }, + }, + }, +}; + +export const schema: Def = { + type: 'object', + definitions, + properties: { + smtp: definitions.smtp, + file: definitions.file, + server: definitions.server, + limit: definitions.limit, + session: definitions.session, + user: definitions.user, + }, + additionalProperties: true, +}; + +export function addDef(key: string, def: Def) { + definitions[key] = def; + schema.properties[key] = definitions[key]; +} diff --git a/packages/hydrooj/src/typeutils.ts b/packages/hydrooj/src/typeutils.ts index 5be09dc67..c19489055 100644 --- a/packages/hydrooj/src/typeutils.ts +++ b/packages/hydrooj/src/typeutils.ts @@ -14,3 +14,4 @@ export type Projection = (string & keyof O)[]; export type Omit = Pick>; export type Filter = T extends U ? T : never; export type MaybeArray = T | T[]; +export type UnionToIntersection = (U extends any ? (arg: U) => void : never) extends ((arg: infer I) => void) ? I : never; diff --git a/packages/ui-default/components/editor/cmeditor.page.ts b/packages/ui-default/components/editor/cmeditor.page.ts index 8579645ad..74e50ce37 100644 --- a/packages/ui-default/components/editor/cmeditor.page.ts +++ b/packages/ui-default/components/editor/cmeditor.page.ts @@ -5,7 +5,10 @@ import CmEditor from '.'; function runSubstitute($container: JQuery) { for (const language of ['markdown', 'yaml', 'json', 'plain']) { $container.find(`textarea[data-${language}]`).get().forEach((element) => { - CmEditor.getOrConstruct($(element), { language }); + const config: any = { language }; + if ($(element).data('model')) config.model = $(element).data('model'); + if ($(element).data('hide')) config.hide = $(element).data('hide'); + CmEditor.getOrConstruct($(element), config); }); } } diff --git a/packages/ui-default/components/editor/cmeditor.styl b/packages/ui-default/components/editor/cmeditor.styl index 2fd457c3a..c5237498b 100644 --- a/packages/ui-default/components/editor/cmeditor.styl +++ b/packages/ui-default/components/editor/cmeditor.styl @@ -6,3 +6,7 @@ .vditor ol list-style: decimal + +.decoration-hide + color: grey !important + background-color: grey !important diff --git a/packages/ui-default/components/editor/index.ts b/packages/ui-default/components/editor/index.ts index 160dad972..c7a61f5e8 100644 --- a/packages/ui-default/components/editor/index.ts +++ b/packages/ui-default/components/editor/index.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { Range } from 'monaco-editor'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; export const config = { @@ -32,6 +33,7 @@ interface MonacoOptions { autoResize?: boolean; autoLayout?: boolean; value?: string; + hide?: string[]; } interface VditorOptions { theme?: 'classic' | 'dark' @@ -54,13 +56,14 @@ export default class Editor extends DOMAttachedObject { async initMonaco() { const { load } = await import('vj/components/monaco/loader'); - const { monaco, registerAction } = await load(['markdown']); const { onChange, language = 'markdown', theme = UserContext.monacoTheme || 'vs-light', model = `file://model-${Math.random().toString(16)}`, autoResize = true, autoLayout = true, + hide = [], } = this.options; + const { monaco, registerAction } = await load([language]); const { $dom } = this; const hasFocus = $dom.is(':focus') || $dom.hasClass('autofocus'); const origin = $dom.get(0); @@ -124,6 +127,14 @@ export default class Editor extends DOMAttachedObject { cfg.scrollBeyondLastLine = false; } this.editor = monaco.editor.create(ele, cfg); + if (hide.length) { + const ranges = []; + for (const text of hide) { + const found = this.model.findMatches(text, true, false, true, '', true); + ranges.push(...found.map((i) => i.range)); + } + this.editor.deltaDecorations([], ranges.map((range) => ({ range, options: { inlineClassName: 'decoration-hide' } }))); + } registerAction(this.editor, this.model, this.$dom); if (autoResize) { this.editor.onDidChangeModelDecorations(() => { diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index 28bc3605a..544d0fd85 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -88,5 +88,9 @@ monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({ fileMatch: ['hydro://problem/file/config.yaml'], schema: problemConfigSchema, }, + { + uri: `${UiContext.cdn_prefix}manage/setting/schema.json`, + fileMatch: ['hydro://system/setting.yaml'], + }, ], }); diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 3a60b998a..c39787c91 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.30.6", + "version": "4.30.7", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", @@ -19,7 +19,7 @@ "@types/qrcode": "^1.4.2", "@types/redux-logger": "^3.0.9", "@types/sockjs-client": "^1.5.1", - "@undefined-moe/monaco-yaml": "^2.5.0", + "@undefined-moe/monaco-yaml": "^2.5.1", "ansi_up": "^5.1.0", "autoprefixer": "^9.8.8", "chalk": "^4.1.2",