core&ui: goodbye, minio (#417)

This commit is contained in:
undefined authored and GitHub committed 2022-08-23 00:40:21 +08:00
1 parent 7abc0a7d1b
commit e06c86e9dd
34 files changed
+405 -317

No files matched your search

-1
View File
@@ -18,4 +18,3 @@ RUN npm i pm2 yarn -g && \
sudo apt-get install clang -y --no-install-recommends && \ sudo apt-get install clang -y --no-install-recommends && \
mkdir -p ~/.hydro && \ mkdir -p ~/.hydro && \
echo '{"host":"127.0.0.1","port":"27017","name":"hydro","username":"","password":""}' >~/.hydro/config.json && \ echo '{"host":"127.0.0.1","port":"27017","name":"hydro","username":"","password":""}' >~/.hydro/config.json && \
echo "MINIO_ACCESS_KEY=minioadmin\nMINIO_SECRET_KEY=minioadmin" >~/.hydro/env
+2 -8
View File
@@ -9,6 +9,7 @@ services:
VARIANT: 16 VARIANT: 16
volumes: volumes:
- ..:/workspace:cached - ..:/workspace:cached
- testdata:/data/file
command: sleep infinity command: sleep infinity
network_mode: service:db network_mode: service:db
db: db:
@@ -16,14 +17,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongodb-data:/data/db - mongodb-data:/data/db
minio:
image: minio/minio:latest
restart: unless-stopped
volumes:
- minio-data:/data
network_mode: service:db
command: minio server /data --console-address ":9001"
volumes: volumes:
mongodb-data: mongodb-data:
minio-data: testdata:
+2 -3
View File
@@ -1,10 +1,9 @@
FROM gitpod/workspace-mongodb FROM gitpod/workspace-mongodb
RUN npm i pm2 -g && \ RUN npm i pm2 -g && \
sudo wget https://dl.min.io/server/minio/release/linux-amd64/minio -O /usr/bin/minio && \
sudo chmod 755 /usr/bin/minio && \
sudo apt-get update && \ sudo apt-get update && \
sudo apt-get install clang -y && \ sudo apt-get install clang -y && \
cargo install sonic-server --version 1.3.0 && \ cargo install sonic-server --version 1.3.0 && \
mkdir -p /home/gitpod/.hydro && \ mkdir -p /home/gitpod/.hydro && \
echo '{"host":"127.0.0.1","port":"27017","name":"hydro","username":"","password":""}' >/home/gitpod/.hydro/config.json && \ echo '{"host":"127.0.0.1","port":"27017","name":"hydro","username":"","password":""}' >/home/gitpod/.hydro/config.json && \
echo "MINIO_ACCESS_KEY=hydro\nMINIO_SECRET_KEY=hydrohydro" >/home/gitpod/.hydro/env mkdir /data/file -p
chmod 777 /data/file
-1
View File
@@ -4,7 +4,6 @@ tasks:
- init: | - init: |
yarn yarn
pm2 start mongod pm2 start mongod
pm2 start "MINIO_ACCESS_KEY=hydro MINIO_SECRET_KEY=hydrohydro minio server /home/gitpod/file" --name minio
yarn build:ui yarn build:ui
npx hydrooj cli system set server.port 2333 npx hydrooj cli system set server.port 2333
npx hydrooj cli user create root@hydro.local root rootroot 2 npx hydrooj cli user create root@hydro.local root rootroot 2
-1
View File
@@ -12,7 +12,6 @@ fi
if [ ! -f "$ROOT/first" ]; then if [ ! -f "$ROOT/first" ]; then
echo "for marking use only!" > "$ROOT/first" echo "for marking use only!" > "$ROOT/first"
hydrooj cli system set file.endPoint http://oj-minio:9000/
hydrooj cli user create systemjudge@systemjudge.local root rootroot hydrooj cli user create systemjudge@systemjudge.local root rootroot
hydrooj cli user setSuperAdmin 2 hydrooj cli user setSuperAdmin 2
+1 -14
View File
@@ -1,16 +1,6 @@
version: '3.7' version: '3.7'
services: services:
oj-minio:
image: minio/minio
container_name: oj-minio
command: server /data
restart: always
volumes:
- ./data/minio:/data
environment:
- MINIO_ACCESS_KEY=CHANGE_THIS
- MINIO_SECRET_KEY=CHANGE_THIS
# Warning: mongodb here is not password-protected. # Warning: mongodb here is not password-protected.
# DO NOT EXPOSE THIS SERVICE TO THE PUBLIC. # DO NOT EXPOSE THIS SERVICE TO THE PUBLIC.
@@ -26,13 +16,10 @@ services:
container_name: oj-backend container_name: oj-backend
restart: always restart: always
depends_on: depends_on:
- oj-minio
- oj-mongo - oj-mongo
volumes: volumes:
- ./data/file:/data/file
- ./data/backend:/root/.hydro - ./data/backend:/root/.hydro
environment:
- MINIO_ACCESS_KEY=CHANGE_THIS
- MINIO_SECRET_KEY=CHANGE_THIS
ports: ports:
- "0.0.0.0:80:8888" # In docker mode, change THIS port instead of port in system settings! - "0.0.0.0:80:8888" # In docker mode, change THIS port instead of port in system settings!
-15
View File
@@ -20,7 +20,6 @@ const locales = {
'error.nodeVersionPraseFail': '无法解析 Node 版本号,请尝试手动安装。', 'error.nodeVersionPraseFail': '无法解析 Node 版本号,请尝试手动安装。',
'install.pm2': '正在安装 PM2...', 'install.pm2': '正在安装 PM2...',
'install.createDatabaseUser': '正在创建数据库用户...', 'install.createDatabaseUser': '正在创建数据库用户...',
'install.minio': '正在安装 MinIO...',
'install.compiler': '正在安装编译器...', 'install.compiler': '正在安装编译器...',
'install.hydro': '正在安装 Hydro...', 'install.hydro': '正在安装 Hydro...',
'install.done': 'Hydro 安装成功!', 'install.done': 'Hydro 安装成功!',
@@ -45,7 +44,6 @@ const locales = {
'error.nodeVersionPraseFail': 'Unable to parse Node version, please try to install manually.', 'error.nodeVersionPraseFail': 'Unable to parse Node version, please try to install manually.',
'install.pm2': 'Installing PM2...', 'install.pm2': 'Installing PM2...',
'install.createDatabaseUser': 'Creating database user...', 'install.createDatabaseUser': 'Creating database user...',
'install.minio': 'Installing MinIO...',
'install.compiler': 'Installing compiler...', 'install.compiler': 'Installing compiler...',
'install.hydro': 'Installing Hydro...', 'install.hydro': 'Installing Hydro...',
'install.done': 'Hydro installation completed!', 'install.done': 'Hydro installation completed!',
@@ -83,8 +81,6 @@ if (!cpuInfoFile.includes('avx2')) {
let migration; let migration;
let retry = 0; let retry = 0;
log.info('install.start'); log.info('install.start');
const MINIO_ACCESS_KEY = randomstring(32);
const MINIO_SECRET_KEY = randomstring(32);
let DATABASE_PASSWORD = randomstring(32); let DATABASE_PASSWORD = randomstring(32);
// TODO read from args // TODO read from args
const CN = true; const CN = true;
@@ -193,13 +189,6 @@ To disable this feature, checkout our sourcecode.`);
skip: () => !exec('pm2 -v').code, skip: () => !exec('pm2 -v').code,
operations: ['yarn global add pm2'], operations: ['yarn global add pm2'],
}, },
{
init: 'install.minio',
skip: () => !exec('minio -v').code,
operations: [
'nix-env -iA nixpkgs.minio',
],
},
{ {
init: 'install.compiler', init: 'install.compiler',
operations: [ operations: [
@@ -249,8 +238,6 @@ To disable this feature, checkout our sourcecode.`);
operations: [ operations: [
['pm2 stop all', { ignore: true }], ['pm2 stop all', { ignore: true }],
() => fs.writefile(`${__env.HOME}/.hydro/mount.yaml`, mount), () => fs.writefile(`${__env.HOME}/.hydro/mount.yaml`, mount),
`echo "MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}\nMINIO_SECRET_KEY=${MINIO_SECRET_KEY}" >/root/.hydro/env`,
`pm2 start "MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} MINIO_SECRET_KEY=${MINIO_SECRET_KEY} minio server /data/file" --name minio`,
'pm2 start mongod --name mongodb -- --auth --bind_ip 0.0.0.0', 'pm2 start mongod --name mongodb -- --auth --bind_ip 0.0.0.0',
() => sleep(1000), () => sleep(1000),
`pm2 start bash --name hydro-sandbox -- -c "ulimit -s unlimited && hydro-sandbox -mount-conf ${__env.HOME}/.hydro/mount.yaml"`, `pm2 start bash --name hydro-sandbox -- -c "ulimit -s unlimited && hydro-sandbox -mount-conf ${__env.HOME}/.hydro/mount.yaml"`,
@@ -291,8 +278,6 @@ To disable this feature, checkout our sourcecode.`);
() => log.info('extra.restartTerm'), () => log.info('extra.restartTerm'),
() => log.info('extra.dbUser'), () => log.info('extra.dbUser'),
() => log.info('extra.dbPassword', DATABASE_PASSWORD), () => log.info('extra.dbPassword', DATABASE_PASSWORD),
() => log.info('MINIO_ACCESS_KEY=%s', MINIO_ACCESS_KEY),
() => log.info('MINIO_SECRET_KEY=%s', MINIO_SECRET_KEY),
], ],
}, },
]; ];
-1
View File
@@ -15,7 +15,6 @@ in pkgs.dockerTools.buildImage {
name = "hydro-web"; name = "hydro-web";
paths = [ paths = [
hydro.mongodb4 hydro.mongodb4
pkgs.minio
pkgs.nodejs pkgs.nodejs
pkgs.yarn pkgs.yarn
]; ];
-1
View File
@@ -6,7 +6,6 @@ in pkgs.buildEnv {
name = "hydro-env"; name = "hydro-env";
paths = [ paths = [
mongo mongo
pkgs.minio
pkgs.nodejs pkgs.nodejs
pkgs.yarn pkgs.yarn
pkgs.git pkgs.git
+1 -2
View File
@@ -15,7 +15,6 @@ mongo 127.0.0.1:27017/hydro /tmp/createUser.js
echo "{\"host\":\"127.0.0.1\",\"port\":\"27017\",\"name\":\"hydro\",\"username\":\"hydro\",\"password\":\"$db_password\"}" >~/.hydro/config.json echo "{\"host\":\"127.0.0.1\",\"port\":\"27017\",\"name\":\"hydro\",\"username\":\"hydro\",\"password\":\"$db_password\"}" >~/.hydro/config.json
pm2 stop mongod pm2 stop mongod
pm2 del mongod pm2 del mongod
pm2 restart minio
pm2 start mongodb pm2 start mongodb
pm2 restart hydrooj pm2 restart all
pm2 save pm2 save
-9
View File
@@ -7,8 +7,6 @@ echo "详情请参阅文档 -> https://hydro.js.org"
echo "使用 Ctrl-C 退出该脚本,或是等待十秒后继续。" echo "使用 Ctrl-C 退出该脚本,或是等待十秒后继续。"
echo "Will continue installation in 10 secs, press Ctrl-C to exit" echo "Will continue installation in 10 secs, press Ctrl-C to exit"
sleep 10 sleep 10
MINIO_ACCESS_KEY=$(cat /dev/urandom | head -n 10 | md5sum | head -c 20)
MINIO_SECRET_KEY=$(cat /dev/urandom | head -n 10 | md5sum | head -c 20)
# Basic # Basic
echo "apt-get update" echo "apt-get update"
@@ -54,11 +52,6 @@ pm2 del mongod >/dev/null
echo 'Starting mongodb' echo 'Starting mongodb'
pm2 start "mongod --auth --bind_ip 0.0.0.0" --name mongodb pm2 start "mongod --auth --bind_ip 0.0.0.0" --name mongodb
# Install MinIO
wget http://dl.minio.org.cn/server/minio/release/linux-amd64/minio
chmod +x minio
pm2 start "MINIO_ACCESS_KEY=$MINIO_ACCESS_KEY MINIO_SECRET_KEY=$MINIO_SECRET_KEY ./minio server /data/file" --name minio
# Install Compiler # Install Compiler
echo 'Installing g++' echo 'Installing g++'
apt-get install -y g++ >/dev/null apt-get install -y g++ >/dev/null
@@ -81,5 +74,3 @@ pm2 save
echo "Done" echo "Done"
echo "Database username: hydro" echo "Database username: hydro"
echo "Database password: $db_password" echo "Database password: $db_password"
echo "MINIO_ACCESS_KEY=$MINIO_ACCESS_KEY
MINIO_SECRET_KEY=$MINIO_SECRET_KEY" >~/.hydro/env
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "hydrooj", "name": "hydrooj",
"version": "3.15.19", "version": "3.16.0",
"bin": "bin/hydrooj.js", "bin": "bin/hydrooj.js",
"main": "src/loader", "main": "src/loader",
"module": "src/loader", "module": "src/loader",
@@ -15,7 +15,6 @@
"@graphql-tools/schema": "^8.5.1", "@graphql-tools/schema": "^8.5.1",
"@hydrooj/utils": "workspace:*", "@hydrooj/utils": "workspace:*",
"adm-zip": "0.5.5", "adm-zip": "0.5.5",
"ajv": "^8.11.0",
"cac": "^6.7.12", "cac": "^6.7.12",
"cookies": "^0.8.0", "cookies": "^0.8.0",
"detect-browser": "^5.3.0", "detect-browser": "^5.3.0",
+2 -1
View File
@@ -88,8 +88,9 @@ export async function load() {
require('../options'); require('../options');
const opts = options(); const opts = options();
await db.start(opts); await db.start(opts);
await require('../settings').loadConfig();
const storage = require('../service/storage'); const storage = require('../service/storage');
await storage.start(); await storage.loadStorageService();
require('../lib/index'); require('../lib/index');
await lib(pending, fail); await lib(pending, fail);
const systemModel = require('../model/system'); const systemModel = require('../model/system');
+2 -1
View File
@@ -37,12 +37,13 @@ export async function load() {
if (detail) logger.info('finish: locale/template/static'); if (detail) logger.info('finish: locale/template/static');
const opts = options(); const opts = options();
await db.start(opts); await db.start(opts);
await require('../settings').loadConfig();
if (detail) logger.info('finish: db.connect'); if (detail) logger.info('finish: db.connect');
const modelSystem = require('../model/system'); const modelSystem = require('../model/system');
await modelSystem.runConfig(); await modelSystem.runConfig();
if (detail) logger.info('finish: config'); if (detail) logger.info('finish: config');
const storage = require('../service/storage'); const storage = require('../service/storage');
await storage.start(); await storage.loadStorageService();
if (detail) logger.info('finish: storage.connect'); if (detail) logger.info('finish: storage.connect');
require('../lib/index'); require('../lib/index');
if (detail) logger.info('finish: lib.builtin'); if (detail) logger.info('finish: lib.builtin');
+21 -51
View File
@@ -1,7 +1,7 @@
import { exec } from 'child_process'; import { exec } from 'child_process';
import { inspect } from 'util'; import { inspect } from 'util';
import Ajv from 'ajv';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import Schema from 'schemastery';
import * as check from '../check'; import * as check from '../check';
import { BadRequestError, ValidationError } from '../error'; import { BadRequestError, ValidationError } from '../error';
import { import {
@@ -19,12 +19,10 @@ import {
Connection, ConnectionHandler, Handler, Connection, ConnectionHandler, Handler,
param, Route, Types, param, Route, Types,
} from '../service/server'; } from '../service/server';
import { schema } from '../settings'; import { configSource, saveConfig, SystemSettings } from '../settings';
import * as judge from './judge'; import * as judge from './judge';
const logger = new Logger('manage'); const logger = new Logger('manage');
const ajv = new Ajv({ useDefaults: true });
const validator = ajv.compile<any>(schema);
function set(key: string, value: any) { function set(key: string, value: any) {
if (setting.SYSTEM_SETTINGS_BY_KEY[key]) { if (setting.SYSTEM_SETTINGS_BY_KEY[key]) {
@@ -156,55 +154,12 @@ class SystemSettingHandler extends SystemHandler {
for (const s of this.response.body.settings) { for (const s of this.response.body.settings) {
this.response.body.current[s.key] = system.get(s.key); 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) { async post(args: any) {
const tasks = []; const tasks = [];
const booleanKeys = args.booleanKeys || {}; const booleanKeys = args.booleanKeys || {};
delete 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) { for (const key in args) {
if (typeof args[key] === 'object') { if (typeof args[key] === 'object') {
for (const subkey in args[key]) { for (const subkey in args[key]) {
@@ -228,10 +183,25 @@ class SystemSettingHandler extends SystemHandler {
} }
} }
class SystemSettingSchemaHandler extends SystemHandler { class SystemConfigHandler extends SystemHandler {
async get() { async get() {
this.response.body = JSON.stringify(schema); this.response.template = 'manage_config.html';
this.response.type = 'application/json'; this.response.body = {
schema: Schema.intersect(SystemSettings).toJSON(),
value: configSource,
};
}
@param('value', Types.String)
async post(domainId: string, value: string) {
let config;
try {
config = yaml.load(value);
Schema.intersect(SystemSettings)(config);
} catch (e) {
throw new ValidationError('value');
}
await saveConfig(config);
} }
} }
@@ -294,7 +264,7 @@ async function apply() {
Route('manage_dashboard', '/manage/dashboard', SystemDashboardHandler); Route('manage_dashboard', '/manage/dashboard', SystemDashboardHandler);
Route('manage_script', '/manage/script', SystemScriptHandler); Route('manage_script', '/manage/script', SystemScriptHandler);
Route('manage_setting', '/manage/setting', SystemSettingHandler); Route('manage_setting', '/manage/setting', SystemSettingHandler);
Route('manage_setting_schema', '/manage/setting/schema.json', SystemSettingSchemaHandler); Route('manage_config', '/manage/config', SystemConfigHandler);
Route('manage_user_import', '/manage/userimport', SystemUserImportHandler); Route('manage_user_import', '/manage/userimport', SystemUserImportHandler);
Connection('manage_check', '/manage/check-conn', SystemCheckConnHandler); Connection('manage_check', '/manage/check-conn', SystemCheckConnHandler);
} }
+22
View File
@@ -1,9 +1,11 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import { statSync } from 'fs'; import { statSync } from 'fs';
import { pick } from 'lodash'; import { pick } from 'lodash';
import { lookup } from 'mime-types';
import { import {
BadRequestError, ForbiddenError, ValidationError, BadRequestError, ForbiddenError, ValidationError,
} from '../error'; } from '../error';
import { md5 } from '../lib/crypto';
import { PRIV } from '../model/builtin'; import { PRIV } from '../model/builtin';
import * as oplog from '../model/oplog'; import * as oplog from '../model/oplog';
import storage from '../model/storage'; import storage from '../model/storage';
@@ -12,6 +14,8 @@ import user from '../model/user';
import { import {
Handler, param, post, Route, Types, Handler, param, post, Route, Types,
} from '../service/server'; } from '../service/server';
import { encodeRFC5987ValueChars } from '../service/storage';
import { builtinConfig } from '../settings';
import { sortFiles } from '../utils'; import { sortFiles } from '../utils';
class SwitchLanguageHandler extends Handler { class SwitchLanguageHandler extends Handler {
@@ -101,6 +105,23 @@ export class FSDownloadHandler extends Handler {
} }
} }
export class StorageHandler extends Handler {
@param('target', Types.Name)
@param('filename', Types.Name, true)
@param('expire', Types.UnsignedInt)
@param('secret', Types.String)
async get(domainId: string, target: string, filename = '', expire: number, secret: string) {
const expected = md5(`${target}/${expire}/${builtinConfig.file.secret}`);
if (expire < Date.now()) throw new ForbiddenError('Link expired');
if (secret !== expected) throw new ForbiddenError('Invalid secret');
this.response.body = await storage.get(target);
this.response.type = (target.endsWith('.out') || target.endsWith('.ans'))
? 'text/plain'
: lookup(target) || 'application/octet-stream';
if (filename) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(filename)}"`;
}
}
export class SwitchAccountHandler extends Handler { export class SwitchAccountHandler extends Handler {
@param('uid', Types.Int) @param('uid', Types.Int)
async get(domainId: string, uid: number) { async get(domainId: string, uid: number) {
@@ -113,6 +134,7 @@ export async function apply() {
Route('switch_language', '/language/:lang', SwitchLanguageHandler); Route('switch_language', '/language/:lang', SwitchLanguageHandler);
Route('home_files', '/file', FilesHandler); Route('home_files', '/file', FilesHandler);
Route('fs_download', '/file/:uid/:filename', FSDownloadHandler); Route('fs_download', '/file/:uid/:filename', FSDownloadHandler);
Route('storage', '/storage', StorageHandler);
Route('switch_account', '/account', SwitchAccountHandler, PRIV.PRIV_EDIT_SYSTEM); Route('switch_account', '/account', SwitchAccountHandler, PRIV.PRIV_EDIT_SYSTEM);
} }
-8
View File
@@ -183,14 +183,6 @@ const ignoreUA = [
].join('\n'); ].join('\n');
SystemSetting( SystemSetting(
Setting('setting_file', 'file.endPoint', 'http://127.0.0.1:9000', 'text', 'file.endPoint', 'Storage engine endPoint'),
Setting('setting_file', 'file.accessKey', null, 'text', 'file.accessKey', 'Storage engine accessKey'),
Setting('setting_file', 'file.secretKey', null, 'password', 'file.secretKey', 'Storage engine secret', FLAG_SECRET),
Setting('setting_file', 'file.bucket', 'hydro', 'text', 'file.bucket', 'Storage engine bucket'),
Setting('setting_file', 'file.region', 'us-east-1', 'text', 'file.region', 'Storage engine region'),
Setting('setting_file', 'file.pathStyle', true, 'boolean', 'file.pathStyle', 'pathStyle endpoint'),
Setting('setting_file', 'file.endPointForUser', '/fs/', 'text', 'file.endPointForUser', 'EndPoint for user'),
Setting('setting_file', 'file.endPointForJudge', '/fs/', 'text', 'file.endPointForJudge', 'EndPoint for judge'),
Setting('setting_smtp', 'smtp.user', null, 'text', 'smtp.user', 'SMTP Username'), Setting('setting_smtp', 'smtp.user', null, 'text', 'smtp.user', 'SMTP Username'),
Setting('setting_smtp', 'smtp.pass', null, 'password', 'smtp.pass', 'SMTP Password', FLAG_SECRET), Setting('setting_smtp', 'smtp.pass', null, 'password', 'smtp.pass', 'SMTP Password', FLAG_SECRET),
Setting('setting_smtp', 'smtp.host', null, 'text', 'smtp.host', 'SMTP Server Host'), Setting('setting_smtp', 'smtp.host', null, 'text', 'smtp.host', 'SMTP Server Host'),
+5 -14
View File
@@ -1,7 +1,6 @@
import { extname } from 'path'; import { extname } from 'path';
import { escapeRegExp } from 'lodash'; import { escapeRegExp } from 'lodash';
import { lookup } from 'mime-types'; import { lookup } from 'mime-types';
import { ItemBucketMetadata } from 'minio';
import moment from 'moment'; import moment from 'moment';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { Readable } from 'stream'; import type { Readable } from 'stream';
@@ -14,11 +13,8 @@ import TaskModel from './task';
export class StorageModel { export class StorageModel {
static coll = db.collection('storage'); static coll = db.collection('storage');
static async put(path: string, file: string | Buffer | Readable, meta?: ItemBucketMetadata, owner?: number); static async put(path: string, file: string | Buffer | Readable, owner?: number) {
static async put(path: string, file: string | Buffer | Readable, owner?: number); const meta = {};
static async put(path: string, file: string | Buffer | Readable, arg0?: ItemBucketMetadata | number, arg1?: number) {
const meta = typeof arg0 === 'object' ? arg0 : {};
const owner = (typeof arg0 === 'number' ? arg0 : arg1) ?? 1;
await StorageModel.del([path]); await StorageModel.del([path]);
meta['Content-Type'] = (path.endsWith('.ans') || path.endsWith('.out')) meta['Content-Type'] = (path.endsWith('.ans') || path.endsWith('.out'))
? 'text/plain' ? 'text/plain'
@@ -27,7 +23,7 @@ export class StorageModel {
// Make sure id is not used // Make sure id is not used
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
while (await StorageModel.coll.findOne({ _id })) _id = `${nanoid(3)}/${nanoid()}${extname(path)}`; while (await StorageModel.coll.findOne({ _id })) _id = `${nanoid(3)}/${nanoid()}${extname(path)}`;
await storage.put(_id, file, meta); await storage.put(_id, file);
const { metaData, size, etag } = await storage.getMeta(_id); const { metaData, size, etag } = await storage.getMeta(_id);
await StorageModel.coll.insertOne({ await StorageModel.coll.insertOne({
_id, meta: metaData, path, size, etag, lastModified: new Date(), owner, _id, meta: metaData, path, size, etag, lastModified: new Date(), owner,
@@ -63,13 +59,8 @@ export class StorageModel {
static async list(target: string, recursive = true) { static async list(target: string, recursive = true) {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path'); if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
if (target.length && !target.endsWith('/')) target += '/'; if (target.length && !target.endsWith('/')) target += '/';
const results = recursive const results = await StorageModel.coll.find({
? await StorageModel.coll.find({ path: { $regex: new RegExp(`^${escapeRegExp(target)}${recursive ? '' : '[^/]+$'}`, 'i') },
path: { $regex: new RegExp(`^${escapeRegExp(target)}`, 'i') },
autoDelete: null,
}).toArray()
: await StorageModel.coll.find({
path: { $regex: new RegExp(`^${escapeRegExp(target)}[^/]+$`) },
autoDelete: null, autoDelete: null,
}).toArray(); }).toArray();
return results.map((i) => ({ return results.map((i) => ({
+4 -4
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { existsSync, readFileSync } from 'fs-extra';
import { findFileSync } from '@hydrooj/utils/lib/utils'; import { findFileSync } from '@hydrooj/utils/lib/utils';
import { Logger } from './logger'; import { Logger } from './logger';
@@ -8,8 +8,8 @@ const logger = new Logger('options');
export = function load() { export = function load() {
const envFile = path.resolve(os.homedir(), '.hydro', 'env'); const envFile = path.resolve(os.homedir(), '.hydro', 'env');
if (fs.existsSync(envFile)) { if (existsSync(envFile)) {
const content = fs.readFileSync(envFile).toString().replace(/\r/g, ''); const content = readFileSync(envFile).toString().replace(/\r/g, '');
for (const line of content.split('\n')) { for (const line of content.split('\n')) {
if (!line.includes('=')) continue; if (!line.includes('=')) continue;
process.env[line.split('=')[0]] = line.split('=')[1].trim(); process.env[line.split('=')[0]] = line.split('=')[1].trim();
@@ -19,7 +19,7 @@ export = function load() {
if (!f) return null; if (!f) return null;
let result: any = {}; let result: any = {};
try { try {
result = JSON.parse(fs.readFileSync(f).toString()); result = JSON.parse(readFileSync(f, 'utf-8'));
} catch (e) { } catch (e) {
logger.error('Cannot read config file %o', e); logger.error('Cannot read config file %o', e);
result = {}; result = {};
+14 -6
View File
@@ -20,6 +20,7 @@ import { PERM, PRIV } from '../model/builtin';
import * as opcount from '../model/opcount'; import * as opcount from '../model/opcount';
import * as system from '../model/system'; import * as system from '../model/system';
import { User } from '../model/user'; import { User } from '../model/user';
import { builtinConfig } from '../settings';
import { errorMessage } from '../utils'; import { errorMessage } from '../utils';
import * as bus from './bus'; import * as bus from './bus';
import * as decorators from './decorators'; import * as decorators from './decorators';
@@ -29,6 +30,7 @@ import rendererLayer from './layers/renderer';
import responseLayer from './layers/response'; import responseLayer from './layers/response';
import userLayer from './layers/user'; import userLayer from './layers/user';
import { Router } from './router'; import { Router } from './router';
import { encodeRFC5987ValueChars } from './storage';
export * from './decorators'; export * from './decorators';
@@ -109,11 +111,17 @@ const serializer = (showDisplayName = false) => (k: string, v: any) => {
export async function prepare() { export async function prepare() {
app.keys = system.get('session.keys') as unknown as string[]; app.keys = system.get('session.keys') as unknown as string[];
app.use(proxy('/fs', { const proxyMiddleware = proxy('/fs', {
target: system.get('file.endPoint'), target: builtinConfig.file.endPoint,
changeOrigin: true, changeOrigin: true,
rewrite: (p) => p.replace('/fs', ''), rewrite: (p) => p.replace('/fs', ''),
})); });
app.use(async (ctx, next) => {
if (!ctx.path.startsWith('/fs/')) return await next();
if (ctx.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(ctx, next);
ctx.request.path = ctx.path = ctx.path.split('/fs')[1];
return await next();
});
app.use(Compress()); app.use(Compress());
for (const dir of global.publicDirs) { for (const dir of global.publicDirs) {
app.use(cache(dir, { app.use(cache(dir, {
@@ -122,9 +130,9 @@ export async function prepare() {
} }
if (process.env.DEV) { if (process.env.DEV) {
app.use(async (ctx: Context, next: Function) => { app.use(async (ctx: Context, next: Function) => {
const startTime = new Date().getTime(); const startTime = Date.now();
await next(); await next();
const endTime = new Date().getTime(); const endTime = Date.now();
if (ctx.nolog || ctx.response.headers.nolog) return; if (ctx.nolog || ctx.response.headers.nolog) return;
ctx._remoteAddress = ctx.request.ip; ctx._remoteAddress = ctx.request.ip;
logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \ logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \
@@ -216,7 +224,7 @@ export class Handler extends HandlerCommon {
this.response.body = data; this.response.body = data;
this.response.template = null; this.response.template = null;
this.response.type = 'application/octet-stream'; this.response.type = 'application/octet-stream';
if (name) this.response.disposition = `attachment; filename="${encodeURIComponent(name)}"`; if (name) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(name)}"`;
} }
async init() { async init() {
+124 -36
View File
@@ -1,9 +1,16 @@
import { dirname, resolve } from 'path';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { URL } from 'url'; import { URL } from 'url';
import { createReadStream } from 'fs-extra'; import {
copyFile, createReadStream, ensureDir,
remove, stat, writeFile,
} from 'fs-extra';
import { lookup } from 'mime-types';
import { BucketItem, Client, ItemBucketMetadata } from 'minio'; import { BucketItem, Client, ItemBucketMetadata } from 'minio';
import { md5 } from '../lib/crypto';
import { Logger } from '../logger'; import { Logger } from '../logger';
import * as system from '../model/system'; import { builtinConfig } from '../settings';
import { MaybeArray } from '../typeutils';
const logger = new Logger('storage'); const logger = new Logger('storage');
@@ -18,38 +25,38 @@ interface StorageOptions {
endPointForJudge?: string; endPointForJudge?: string;
} }
interface MinioEndpointConfig { interface EndpointConfig {
endPoint: string; endPoint: string;
port: number; port: number;
useSSL: boolean; useSSL: boolean;
} }
function parseMainEndpointUrl(endpoint: string): MinioEndpointConfig { function parseMainEndpointUrl(endpoint: string): EndpointConfig {
if (!endpoint) throw new Error('Empty endpoint'); if (!endpoint) throw new Error('Empty endpoint');
const url = new URL(endpoint); const url = new URL(endpoint);
const result: Partial<MinioEndpointConfig> = {}; const result: Partial<EndpointConfig> = {};
if (url.pathname !== '/') throw new Error('Main MinIO endpoint URL of a sub-directory is not supported.'); if (url.pathname !== '/') throw new Error('Main endpoint URL of a sub-directory is not supported.');
if (url.username || url.password || url.hash || url.search) { if (url.username || url.password || url.hash || url.search) {
throw new Error('Authorization, search parameters and hash are not supported for main MinIO endpoint URL.'); throw new Error('Authorization, search parameters and hash are not supported for main endpoint URL.');
} }
if (url.protocol === 'http:') result.useSSL = false; if (url.protocol === 'http:') result.useSSL = false;
else if (url.protocol === 'https:') result.useSSL = true; else if (url.protocol === 'https:') result.useSSL = true;
else { else {
throw new Error( throw new Error(
`Invalid protocol "${url.protocol}" for main MinIO endpoint URL. Only HTTP and HTTPS are supported.`, `Invalid protocol "${url.protocol}" for main endpoint URL. Only HTTP and HTTPS are supported.`,
); );
} }
result.endPoint = url.hostname; result.endPoint = url.hostname;
result.port = url.port ? Number(url.port) : result.useSSL ? 443 : 80; result.port = url.port ? Number(url.port) : result.useSSL ? 443 : 80;
return result as MinioEndpointConfig; return result as EndpointConfig;
} }
function parseAlternativeEndpointUrl(endpoint: string): (originalUrl: string) => string { function parseAlternativeEndpointUrl(endpoint: string): (originalUrl: string) => string {
if (!endpoint) return (originalUrl) => originalUrl; if (!endpoint) return (originalUrl) => originalUrl;
const pathonly = endpoint.startsWith('/'); const pathonly = endpoint.startsWith('/');
if (pathonly) endpoint = `https://localhost${endpoint}`; if (pathonly) endpoint = `https://localhost${endpoint}`;
const url = new URL(endpoint); const url = new URL(endpoint);
if (url.hash || url.search) throw new Error('Search parameters and hash are not supported for alternative MinIO endpoint URL.'); if (url.hash || url.search) throw new Error('Search parameters and hash are not supported for alternative endpoint URL.');
if (!url.pathname.endsWith('/')) throw new Error("Alternative MinIO endpoint URL's pathname must ends with '/'."); if (!url.pathname.endsWith('/')) throw new Error("Alternative endpoint URL's pathname must ends with '/'.");
return (originalUrl) => { return (originalUrl) => {
const parsedOriginUrl = new URL(originalUrl); const parsedOriginUrl = new URL(originalUrl);
const replaced = new URL(parsedOriginUrl.pathname.slice(1) + parsedOriginUrl.search + parsedOriginUrl.hash, url).toString(); const replaced = new URL(parsedOriginUrl.pathname.slice(1) + parsedOriginUrl.search + parsedOriginUrl.hash, url).toString();
@@ -59,7 +66,7 @@ function parseAlternativeEndpointUrl(endpoint: string): (originalUrl: string) =>
}; };
} }
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
function encodeRFC5987ValueChars(str: string) { export function encodeRFC5987ValueChars(str: string) {
return ( return (
encodeURIComponent(str) encodeURIComponent(str)
// Note that although RFC3986 reserves "!", RFC5987 does not, // Note that although RFC3986 reserves "!", RFC5987 does not,
@@ -72,7 +79,7 @@ function encodeRFC5987ValueChars(str: string) {
); );
} }
class StorageService { class RemoteStorageService {
public client: Client; public client: Client;
public error = ''; public error = '';
public opts: StorageOptions; public opts: StorageOptions;
@@ -80,13 +87,17 @@ class StorageService {
async start() { async start() {
try { try {
const [ logger.info('Starting storage service with endpoint:', builtinConfig.file.endPoint);
endPoint, accessKey, secretKey, bucket, region, const {
pathStyle, endPointForUser, endPointForJudge, endPoint,
] = system.getMany([ accessKey,
'file.endPoint', 'file.accessKey', 'file.secretKey', 'file.bucket', 'file.region', secretKey,
'file.pathStyle', 'file.endPointForUser', 'file.endPointForJudge', bucket,
]); region,
pathStyle,
endPointForUser,
endPointForJudge,
} = builtinConfig.file;
this.opts = { this.opts = {
endPoint, endPoint,
accessKey, accessKey,
@@ -97,11 +108,6 @@ class StorageService {
endPointForUser, endPointForUser,
endPointForJudge, endPointForJudge,
}; };
if (process.env.MINIO_ACCESS_KEY) {
logger.info('Using MinIO key from environment variables');
this.opts.accessKey = process.env.MINIO_ACCESS_KEY;
this.opts.secretKey = process.env.MINIO_SECRET_KEY;
}
this.client = new Client({ this.client = new Client({
...parseMainEndpointUrl(this.opts.endPoint), ...parseMainEndpointUrl(this.opts.endPoint),
pathStyle: this.opts.pathStyle, pathStyle: this.opts.pathStyle,
@@ -133,7 +139,7 @@ class StorageService {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path'); if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
if (typeof file === 'string') file = createReadStream(file); if (typeof file === 'string') file = createReadStream(file);
try { try {
return await this.client.putObject(this.opts.bucket, target, file, meta); await this.client.putObject(this.opts.bucket, target, file, meta);
} catch (e) { } catch (e) {
e.stack = new Error().stack; e.stack = new Error().stack;
throw e; throw e;
@@ -154,11 +160,7 @@ class StorageService {
async del(target: string | string[]) { async del(target: string | string[]) {
if (typeof target === 'string') { if (typeof target === 'string') {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path'); if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
} else { } else if (target.find((t) => t.includes('..') || t.includes('//'))) throw new Error('Invalid path');
for (const t of target) {
if (t.includes('..') || t.includes('//')) throw new Error('Invalid path');
}
}
try { try {
if (typeof target === 'string') return await this.client.removeObject(this.opts.bucket, target); if (typeof target === 'string') return await this.client.removeObject(this.opts.bucket, target);
return await this.client.removeObjects(this.opts.bucket, target); return await this.client.removeObjects(this.opts.bucket, target);
@@ -173,7 +175,7 @@ class StorageService {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path'); if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
try { try {
const stream = this.client.listObjects(this.opts.bucket, target, recursive); const stream = this.client.listObjects(this.opts.bucket, target, recursive);
return await new Promise<BucketItem[]>((resolve, reject) => { return await new Promise<BucketItem[]>((r, reject) => {
const results: BucketItem[] = []; const results: BucketItem[] = [];
stream.on('data', (result) => { stream.on('data', (result) => {
if (result.size) { if (result.size) {
@@ -184,7 +186,7 @@ class StorageService {
}); });
} }
}); });
stream.on('end', () => resolve(results)); stream.on('end', () => r(results));
stream.on('error', reject); stream.on('error', reject);
}); });
} catch (e) { } catch (e) {
@@ -237,6 +239,92 @@ class StorageService {
} }
} }
const service = new StorageService(); class LocalStorageService {
global.Hydro.service.storage = service; client: null;
export = service; error: null;
dir: string;
opts: null;
private replaceWithAlternativeUrlFor: Record<'user' | 'judge', (originalUrl: string) => string>;
async start() {
logger.debug('Loading local storage service with path:', builtinConfig.file.path);
await ensureDir(builtinConfig.file.path);
this.dir = builtinConfig.file.path;
this.replaceWithAlternativeUrlFor = {
user: parseAlternativeEndpointUrl(builtinConfig.file.endPointForUser),
judge: parseAlternativeEndpointUrl(builtinConfig.file.endPointForJudge),
};
}
async put(target: string, file: string | Buffer | Readable) {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
target = resolve(this.dir, target);
await ensureDir(dirname(target));
if (typeof file === 'string') await copyFile(file, target);
else await writeFile(target, file);
}
async get(target: string, path?: string) {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
target = resolve(this.dir, target);
if (path) await copyFile(target, path);
return createReadStream(target);
}
async del(target: MaybeArray<string>) {
const targets = typeof target === 'string' ? [target] : target;
if (targets.find((i) => i.includes('..') || i.includes('//'))) throw new Error('Invalid path');
await Promise.all(targets.map((i) => remove(resolve(this.dir, i))));
}
async getMeta(target: string) {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
target = resolve(this.dir, target);
const file = await stat(target);
return {
size: file.size,
etag: Buffer.from(target).toString('base64'),
lastModified: file.mtime,
metaData: {
'Content-Type': (target.endsWith('.ans') || target.endsWith('.out'))
? 'text/plain'
: lookup(target) || 'application/octet-stream',
'Content-Length': file.size,
},
};
}
async signDownloadLink(target: string, filename = '', noExpire = false, useAlternativeEndpointFor?: 'user' | 'judge'): Promise<string> {
if (target.includes('..') || target.includes('//')) throw new Error('Invalid path');
const url = new URL('https://localhost/storage');
url.searchParams.set('target', target);
if (filename) url.searchParams.set('filename', filename);
const expire = (Date.now() + (noExpire ? 7 * 24 * 3600 : 600) * 1000).toString();
url.searchParams.set('expire', expire);
url.searchParams.set('secret', md5(`${target}/${expire}/${builtinConfig.file.secret}`));
if (useAlternativeEndpointFor) return this.replaceWithAlternativeUrlFor[useAlternativeEndpointFor](url.toString());
return `/${url.toString().split('localhost/')[1]}`;
}
async signUpload() {
throw new Error('Not implemented');
}
async list() {
throw new Error('deprecated');
}
}
let service; // eslint-disable-line import/no-mutable-exports
export async function loadStorageService() {
service = builtinConfig.file.type === 's3' ? new RemoteStorageService() : new LocalStorageService();
global.Hydro.service.storage = service;
await service.start();
}
export default new Proxy({}, {
get(self, key) {
return service[key];
},
}) as RemoteStorageService | LocalStorageService;
+101 -126
View File
@@ -1,136 +1,111 @@
import { JSONSchema7Definition } from 'json-schema'; import yaml from 'js-yaml';
import { nanoid } from 'nanoid';
import Schema from 'schemastery';
import * as bus from 'hydrooj/src/service/bus';
import { Logger } from './logger';
import { NestKeys } from './typeutils';
type Def = Exclude<JSONSchema7Definition, boolean>; const defaultPath = process.env.CI ? '/tmp/file' : '/data/file/hydro';
const FileSetting = Schema.intersect([
Schema.object({
type: Schema.union([
Schema.const('file').description('local file provider').required(),
Schema.const('s3').description('s3 provider').required(),
] as const).description('provider type').default('file'),
endPointForUser: Schema.string().default('/fs/').required(),
endPointForJudge: Schema.string().default('/fs/').required(),
}).description('setting_file'),
Schema.union([
Schema.object({
type: Schema.const('file').required(),
path: Schema.string().default(defaultPath).description('Storage path').required(),
secret: Schema.string().description('Download file sign secret').default(nanoid()),
}),
Schema.object({
type: Schema.const('s3').required(),
endPoint: Schema.string().required(),
accessKey: Schema.string().required().description('access key'),
secretKey: Schema.string().required().description('secret key').role('secret'),
bucket: Schema.string().default('hydro').required(),
region: Schema.string().default('us-east-1').required(),
pathStyle: Schema.boolean().default(true).required(),
}),
] as const),
] as const).default({
type: 'file',
path: defaultPath,
endPointForUser: '/fs/',
endPointForJudge: '/fs/',
secret: nanoid(),
});
function port(examples: number[] = []) { const builtinSettings = Schema.object({
const res: Def = { file: FileSetting,
type: 'integer', minimum: 1, maximum: 65535, });
}; export const SystemSettings: Schema[] = [builtinSettings];
if (examples.length) { export let configSource = ''; // eslint-disable-line import/no-mutable-exports
res.default = examples[0]; export let systemConfig: any = {}; // eslint-disable-line import/no-mutable-exports
res.examples = examples; const logger = new Logger('settings');
const update = [];
export async function loadConfig() {
const config = await global.Hydro.service.db.collection('system').findOne({ _id: 'config' });
try {
configSource = config?.value || '{}';
systemConfig = yaml.load(configSource);
logger.info('Successfully loaded config');
for (const u of update) u();
} catch (e) {
logger.error('Failed to load config', e.message);
} }
return res; }
export async function saveConfig(config: any) {
Schema.intersect(SystemSettings)(config);
const value = yaml.dump(config);
await global.Hydro.service.db.collection('system').updateOne({ _id: 'config' }, { $set: { value } }, { upsert: true });
bus.broadcast('config/update');
}
export async function setConfig(key: string, value: any) {
const path = key.split('.');
const t = path.pop();
let cursor = systemConfig;
for (const p of path) {
if (!cursor[p]) cursor[p] = {};
cursor = cursor[p];
}
cursor[t] = value;
await saveConfig(systemConfig);
} }
export const Schema = { export function requestConfig<T, S>(s: Schema<T, S>): {
string<T extends Def>(title: string, defaultValue: string, extra?: T) { config: ReturnType<Schema<T, S>>,
setConfig: (key: NestKeys<ReturnType<Schema<T, S>>>, value: any) => Promise<void>,
} {
SystemSettings.push(s);
let curValue = s(systemConfig);
update.push(() => {
try {
curValue = s(systemConfig);
} catch (e) {
logger.warn('Cannot read config: ', e.message);
curValue = null;
}
});
return { return {
type: 'string' as 'string', config: new Proxy(curValue as any, {
default: defaultValue, get(self, key: string) {
title, return curValue?.[key];
...extra,
};
}, },
boolean<T extends Def>(title: string, defaultValue: boolean, extra?: T) { set(self) {
return { throw new Error(`Not allowed to set setting ${self.p.join('.')}`);
type: 'boolean' as 'boolean',
default: defaultValue,
title,
...extra,
};
}, },
integer<T extends Def>(title: string, defaultValue: number, extra?: T) {
return {
type: 'integer' as 'integer',
default: defaultValue,
title,
...extra,
};
},
};
const definitions: Record<string, Def> = {
smtp: {
type: 'object',
properties: {
user: Schema.string('SMTP Username', 'noreply@hydro.ac'),
from: Schema.string('Mail From', 'Hydro <noreply@hydro.ac>'),
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', ''), setConfig,
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];
} }
const builtin = requestConfig(builtinSettings);
export const builtinConfig = builtin.config;
export const setBuiltinConfig = builtin.setConfig;
bus.on('config/update', loadConfig);
+31 -3
View File
@@ -29,6 +29,7 @@ import {
} from './pipelineUtils'; } from './pipelineUtils';
import db from './service/db'; import db from './service/db';
import storage from './service/storage'; import storage from './service/storage';
import { setBuiltinConfig } from './settings';
import { streamToBuffer } from './utils'; import { streamToBuffer } from './utils';
import welcome from './welcome'; import welcome from './welcome';
@@ -94,7 +95,7 @@ const scripts: UpgradeScript[] = [
try { try {
const [file, current] = await Promise.all([ const [file, current] = await Promise.all([
streamToBuffer(gridfs.openDownloadStream(pdoc.data)), streamToBuffer(gridfs.openDownloadStream(pdoc.data)),
storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/testdata/`), storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/testdata/`) as any,
]); ]);
const zip = new AdmZip(file); const zip = new AdmZip(file);
const entries = zip.getEntries(); const entries = zip.getEntries();
@@ -146,8 +147,8 @@ const scripts: UpgradeScript[] = [
await iterateAllProblem(['docId', 'domainId', 'config'], async (pdoc) => { await iterateAllProblem(['docId', 'domainId', 'config'], async (pdoc) => {
logger.info('%s/%s', pdoc.domainId, pdoc.docId); logger.info('%s/%s', pdoc.domainId, pdoc.docId);
const [data, additional_file] = await Promise.all([ const [data, additional_file] = await Promise.all([
storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/testdata/`), storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/testdata/`) as any,
storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/additional_file/`), storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/additional_file/`) as any,
]); ]);
await problem.edit( await problem.edit(
pdoc.domainId, pdoc.docId, pdoc.domainId, pdoc.docId,
@@ -802,6 +803,33 @@ const scripts: UpgradeScript[] = [
}); });
return true; return true;
}, },
async function _66_67() {
const [
endPoint, accessKey, secretKey, bucket, region,
pathStyle, endPointForUser, endPointForJudge,
] = system.getMany([
'file.endPoint', 'file.accessKey', 'file.secretKey', 'file.bucket', 'file.region',
'file.pathStyle', 'file.endPointForUser', 'file.endPointForJudge',
]);
if ((endPoint && accessKey) || process.env.MINIO_ACCESS_KEY) {
await setBuiltinConfig('file', {
type: 's3',
endPoint: process.env.MINIO_ACCESS_KEY ? 'http://127.0.0.1:9000/' : endPoint,
accessKey: process.env.MINIO_ACCESS_KEY || accessKey,
secretKey: process.env.MINIO_SECRET_KEY || secretKey,
bucket,
region,
pathStyle,
endPointForUser,
endPointForJudge,
});
setTimeout(() => {
logger.info('Upgrade done. please restart the server.');
process.exit(0);
}, 1000);
}
return true;
},
]; ];
export default scripts; export default scripts;
+3 -2
View File
@@ -96,7 +96,7 @@ export default function (env: { production?: boolean, measure?: boolean } = {})
chunkFilename: '[name].[chunkhash].chunk.js', chunkFilename: '[name].[chunkhash].chunk.js',
}, },
resolve: { resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'], extensions: ['.js', '.jsx', '.ts', '.tsx', '.cjs'],
alias: { alias: {
vj: root(), vj: root(),
}, },
@@ -148,8 +148,9 @@ export default function (env: { production?: boolean, measure?: boolean } = {})
}, },
}, },
{ {
test: /\.[jt]sx?$/, test: /\.[mc]?[jt]sx?$/,
exclude: /@types\//, exclude: /@types\//,
type: 'javascript/auto',
use: [esbuildLoader()], use: [esbuildLoader()],
}, },
{ {
@@ -107,7 +107,6 @@ async function onCommentClickReplyComment(ev, options: any = {}) {
} }
async function onCommentClickReplyReply(ev) { async function onCommentClickReplyReply(ev) {
console.log(ev);
const $evTarget = $(ev.currentTarget); const $evTarget = $(ev.currentTarget);
const $mediaBody = $evTarget.closest('.media__body'); const $mediaBody = $evTarget.closest('.media__body');
const uid = $mediaBody const uid = $mediaBody
@@ -7,7 +7,6 @@ function runSubstitute($container: JQuery<Document | HTMLElement>) {
$container.find(`textarea[data-${language}]`).get().forEach((element) => { $container.find(`textarea[data-${language}]`).get().forEach((element) => {
const config: any = { language }; const config: any = { language };
if ($(element).data('model')) config.model = $(element).data('model'); if ($(element).data('model')) config.model = $(element).data('model');
if ($(element).data('hide')) config.hide = $(element).data('hide');
CmEditor.getOrConstruct($(element), config); CmEditor.getOrConstruct($(element), config);
}); });
} }
@@ -98,7 +98,7 @@ setDiagnosticsOptions({
schema: problemConfigSchema as any, schema: problemConfigSchema as any,
}, },
{ {
uri: `${UiContext.cdn_prefix}manage/setting/schema.json`, uri: '/manage/config/schema.json',
fileMatch: ['hydro://system/setting.yaml'], fileMatch: ['hydro://system/setting.yaml'],
}, },
], ],
+11
View File
@@ -11,9 +11,12 @@ import user from 'hydrooj/src/model/user';
import * as bus from 'hydrooj/src/service/bus'; import * as bus from 'hydrooj/src/service/bus';
import { UiContextBase } from 'hydrooj/src/service/layers/base'; import { UiContextBase } from 'hydrooj/src/service/layers/base';
import { Handler, Route } from 'hydrooj/src/service/server'; import { Handler, Route } from 'hydrooj/src/service/server';
import { SystemSettings } from 'hydrooj/src/settings';
import { ObjectID } from 'mongodb'; import { ObjectID } from 'mongodb';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import Schema from 'schemastery';
import convert from 'schemastery-jsonschema';
import markdown from './backendlib/markdown'; import markdown from './backendlib/markdown';
declare module 'hydrooj/src/interface' { declare module 'hydrooj/src/interface' {
@@ -157,6 +160,13 @@ class LanguageHandler extends ResourceHandler {
} }
} }
class SystemConfigSchemaHandler extends Handler {
async get() {
const schema = convert(Schema.intersect(SystemSettings) as any, true);
this.response.body = schema;
}
}
class RichMediaHandler extends Handler { class RichMediaHandler extends Handler {
async renderUser(domainId, payload) { async renderUser(domainId, payload) {
let d = payload.domainId || domainId; let d = payload.domainId || domainId;
@@ -213,6 +223,7 @@ global.Hydro.handler.ui = async () => {
Route('set_theme', '/set_theme/:theme', SetThemeHandler); Route('set_theme', '/set_theme/:theme', SetThemeHandler);
Route('constant', '/constant/:version', UiConstantsHandler); Route('constant', '/constant/:version', UiConstantsHandler);
Route('markdown', '/markdown', MarkdownHandler); Route('markdown', '/markdown', MarkdownHandler);
Route('config_schema', '/manage/config/schema.json', SystemConfigSchemaHandler, PRIV.PRIV_EDIT_SYSTEM);
Route('lang', '/l/:lang', LanguageHandler); Route('lang', '/l/:lang', LanguageHandler);
Route('media', '/media', RichMediaHandler); Route('media', '/media', RichMediaHandler);
}; };
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hydrooj/ui-default", "name": "@hydrooj/ui-default",
"version": "4.39.8", "version": "4.39.9",
"author": "undefined <i@undefined.moe>", "author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "hydro.js", "main": "hydro.js",
@@ -129,6 +129,7 @@
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"nunjucks": "^3.2.3", "nunjucks": "^3.2.3",
"p-queue": "^7.3.0", "p-queue": "^7.3.0",
"schemastery-jsonschema": "^1.0.3",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"xss": "^1.0.14" "xss": "^1.0.14"
} }
@@ -0,0 +1,29 @@
import yaml from 'js-yaml';
import Schema from 'schemastery';
import Notification from 'vj/components/notification';
import { NamedPage } from 'vj/misc/Page';
import request from 'vj/utils/request';
const page = new NamedPage('manage_config', async () => {
const schema = new Schema(UiContext.schema);
setInterval(() => {
try {
const v = yaml.load($('#config').val().toString());
schema(v);
$('#info').text('');
} catch (e) {
console.debug(e);
$('#info').text(e.message);
}
}, 1000);
$('#submit').on('click', () => {
const value = $('#config').val();
request.post('', { value }).then(() => {
Notification.success('保存成功');
}).catch((e) => {
Notification.error('保存失败:', e.message);
});
});
});
export default page;
@@ -16,6 +16,7 @@
{{ sidemenu.render_item(null, 'manage_script') }} {{ sidemenu.render_item(null, 'manage_script') }}
{{ sidemenu.render_item(null, 'manage_user_import') }} {{ sidemenu.render_item(null, 'manage_user_import') }}
{{ sidemenu.render_item(null, 'manage_setting') }} {{ sidemenu.render_item(null, 'manage_setting') }}
{{ sidemenu.render_item(null, 'manage_config') }}
</ol> </ol>
</li> </li>
</ol> </ol>
@@ -0,0 +1,24 @@
{% extends "manage_base.html" %}
{% block manage_content %}
{{ set(UiContext, 'schema', schema) }}
<div class="section">
<div class="section__body typo">
<div class="row">
<div class="medium-10 columns form__item">
<label>
config
<div name="form_item_config" class="textarea-container">
<textarea id="config" data-yaml data-model="hydro://system/setting.yaml" placeholder="" class="textbox">{{ value }}</textarea>
</div>
</label>
<p class="help-text" id="info"></p>
</div>
</div>
<div class="row">
<div class="medium-10 columns form__item end">
<button id="submit" class="rounded primary button">{{ _('Save All Changes') }}</button>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -8,7 +8,6 @@
{% if not setting.flag|bitand(model.setting.FLAG_HIDDEN) %} {% if not setting.flag|bitand(model.setting.FLAG_HIDDEN) %}
{% set secret = setting.flag|bitand(model.setting.FLAG_SECRET) != 0 %} {% set secret = setting.flag|bitand(model.setting.FLAG_SECRET) != 0 %}
{% if setting.type == 'text' or setting.type == 'password' or setting.type == 'number' or setting.type == 'float' %} {% if setting.type == 'text' or setting.type == 'password' or setting.type == 'number' or setting.type == 'float' %}
{% set isFileConfig = (setting.name === 'file.accessKey' or setting.name === 'file.secretKey') and process.env.MINIO_ACCESS_KEY %}
{{ form.form_text({ {{ form.form_text({
type:setting.type, type:setting.type,
label:setting.name, label:setting.name,
@@ -16,7 +15,7 @@
name:setting.key, name:setting.key,
value:'' if (secret or isFileConfig) else (current[setting.key]|default(setting.value)), value:'' if (secret or isFileConfig) else (current[setting.key]|default(setting.value)),
disabled:setting.flag|bitand(2), disabled:setting.flag|bitand(2),
placeholder:'Please edit in ~/.hydro/env instead' if isFileConfig else (_('(Not changed)') if secret else '') placeholder:_('(Not changed)') if secret else ''
}) }} }) }}
{% elif setting.type == 'select' %} {% elif setting.type == 'select' %}
{{ form.form_select({ {{ form.form_select({
@@ -214,7 +214,6 @@ export default class CodeforcesProvider implements IBasicProvider {
await page.waitForRequest((req) => { await page.waitForRequest((req) => {
if (req.method() !== 'POST') return false; if (req.method() !== 'POST') return false;
if (!req.url().endsWith('/enter')) return false; if (!req.url().endsWith('/enter')) return false;
console.log(req);
return true; return true;
}, { timeout: 24 * 3600 * 1000 }); }, { timeout: 24 * 3600 * 1000 });
await page.waitForTimeout(10 * 1000); await page.waitForTimeout(10 * 1000);