1.0.0-preview

This commit is contained in:
masnn committed 2020-05-13 12:25:18 +08:00
1 parent 90b06d6aa7
commit 8cd84f29e8
18 files changed
+100 -349

No files matched your search

+6 -2
View File
@@ -22,8 +22,12 @@ jobs:
yarn build:production yarn build:production
- uses: actions/upload-artifact@v1 - uses: actions/upload-artifact@v1
with: with:
name: hydro name: development.js
path: .build path: .build/development.js
- uses: actions/upload-artifact@v1
with:
name: module
path: .build/module
- uses: elgohr/Publish-Docker-Github-Action@master - uses: elgohr/Publish-Docker-Github-Action@master
with: with:
name: hydrooj/hydro:alpine name: hydrooj/hydro:alpine
+1 -1
View File
@@ -269,7 +269,7 @@ class ProblemSolutionHandler extends ProblemDetailHandler {
['problem_solution', null], ['problem_solution', null],
]; ];
this.response.body = { this.response.body = {
path, psdocs, page, pcount, pscount, udict, pssdict, path, psdocs, page, pcount, pscount, udict, pssdict, pdoc: this.pdoc,
}; };
} }
+13 -17
View File
@@ -1,22 +1,6 @@
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const options = require('../options');
const { SendMailError } = require('../error'); const { SendMailError } = require('../error');
let transporter = null;
try {
transporter = nodemailer.createTransport({
host: options.VJ_SMTP_HOST,
port: options.VJ_DB_PORT,
secure: options.VJ_SMTP_SECURE,
auth: {
user: options.VJ_SMTP_USER,
pass: options.VJ_SMTP_PASSWORD,
},
});
} catch (e) {
console.error(e);
}
module.exports = { module.exports = {
/** /**
* @param {string} to * @param {string} to
@@ -27,9 +11,21 @@ module.exports = {
async sendMail(to, subject, text, html) { async sendMail(to, subject, text, html) {
let t; let t;
try { try {
const system = require('../model/system');
const [host, port, secure, user, pass, from] = await Promise.all([
system.get('smtp.host'),
system.get('smtp.port'),
system.get('smtp.secure'),
system.get('smtp.user'),
system.get('smtp.pass'),
system.get('smtp.from'),
]);
const transporter = nodemailer.createTransport({
host, port, secure, auth: { user, pass },
});
t = await new Promise((resolve, reject) => { t = await new Promise((resolve, reject) => {
transporter.sendMail({ transporter.sendMail({
from: options.VJ_SMTP_FROM, from,
to, to,
subject, subject,
text, text,
+17 -37
View File
@@ -1,3 +1,4 @@
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
/* eslint-disable no-eval */ /* eslint-disable no-eval */
const fs = require('fs'); const fs = require('fs');
@@ -19,6 +20,7 @@ function ensureDir(dir) {
} }
const active = []; const active = [];
const fail = [];
try { try {
// Let webpack pack builtin module together. // Let webpack pack builtin module together.
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
@@ -59,7 +61,7 @@ async function preload() {
for (const i of active) { for (const i of active) {
try { try {
if (i.os) { if (i.os) {
if (!i.os.includes(os.platform())) throw new Error('Unsupported OS'); if (!i.os.includes(os.platform().toLowerCase())) throw new Error('Unsupported OS');
} }
if (i.file) { if (i.file) {
i.files = {}; i.files = {};
@@ -87,6 +89,7 @@ async function handler() {
for (const i of active) { for (const i of active) {
if (i.handler && !i.fail) { if (i.handler && !i.fail) {
try { try {
console.log(`Handler init: ${i.id}`);
console.time(`Handler init: ${i.id}`); console.time(`Handler init: ${i.id}`);
const exports = {}; const exports = {};
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@@ -136,6 +139,7 @@ async function model() {
for (const i of active) { for (const i of active) {
if (i.model && !i.fail) { if (i.model && !i.fail) {
try { try {
console.log(`Model init: ${i.id}`);
console.time(`Model init: ${i.id}`); console.time(`Model init: ${i.id}`);
const exports = {}; const exports = {};
const module = { exports, file: i.files || {} }; const module = { exports, file: i.files || {} };
@@ -155,6 +159,7 @@ async function lib() {
for (const i of active) { for (const i of active) {
if (i.lib && !i.fail) { if (i.lib && !i.fail) {
try { try {
console.log(`Lib init: ${i.id}`);
console.time(`Lib init: ${i.id}`); console.time(`Lib init: ${i.id}`);
const exports = {}; const exports = {};
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@@ -184,44 +189,15 @@ async function service() {
i.fail = true; i.fail = true;
fail.push(i.id); fail.push(i.id);
console.error(`Service Load Fail: ${i.id}`); console.error(`Service Load Fail: ${i.id}`);
console.error(e);
} }
} }
} }
} }
async function install() { async function install() {
await Promise.all([lib(), locale(), template()]); const setup = require('./service/setup');
require('./service/setup'); await setup.setup();
}
async function installDb() {
const system = require('./model/system');
const def = {
PROBLEM_PER_PAGE: 100,
RECORD_PER_PAGE: 100,
SOLUTION_PER_PAGE: 20,
CONTEST_PER_PAGE: 20,
TRAINING_PER_PAGE: 10,
DISCUSSION_PER_PAGE: 50,
REPLY_PER_PAGE: 50,
CONTESTS_ON_MAIN: 5,
TRAININGS_ON_MAIN: 5,
DISCUSSIONS_ON_MAIN: 20,
'db.ver': 1,
'listen.https': false,
'listen.port': 8888,
'session.keys': ['Hydro'],
'session.secure': false,
'session.saved_expire_seconds': 3600 * 24,
'session.unsaved_expire_seconds': 600,
changemail_token_expire_seconds: 3600 * 24,
registration_token_expire_seconds: 600,
};
const tasks = [];
for (const key in def) {
tasks.push(system.set(key, def[key]));
}
await Promise.all(tasks);
} }
async function load() { async function load() {
@@ -243,11 +219,12 @@ async function load() {
require('./utils'); require('./utils');
require('./error'); require('./error');
require('./permission'); require('./permission');
await Promise.all([locale(), template()]);
try { try {
require('./options'); require('./options');
} catch (e) { } catch (e) {
await install(); await install();
return; require('./options');
} }
const bus = require('./service/bus'); const bus = require('./service/bus');
await new Promise((resolve) => { await new Promise((resolve) => {
@@ -265,7 +242,7 @@ async function load() {
'template', 'validator', 'nav', 'template', 'validator', 'nav',
]; ];
for (const i of builtinLib) require(`./lib/${i}`); for (const i of builtinLib) require(`./lib/${i}`);
await Promise.all([lib(), locale(), template()]); await lib();
require('./service/gridfs'); require('./service/gridfs');
const server = require('./service/server'); const server = require('./service/server');
await server.prepare(); await server.prepare();
@@ -281,7 +258,10 @@ async function load() {
} }
const system = require('./model/system'); const system = require('./model/system');
const dbVer = await system.get('db.ver'); const dbVer = await system.get('db.ver');
if (dbVer !== 1) await installDb(); if (dbVer !== 1) {
const ins = require('./script/install');
await ins.run();
}
const builtinHandler = [ const builtinHandler = [
'home', 'problem', 'record', 'judge', 'user', 'home', 'problem', 'record', 'judge', 'user',
'contest', 'training', 'discussion', 'manage', 'import', 'contest', 'training', 'discussion', 'manage', 'import',
@@ -300,4 +280,4 @@ async function load() {
await server.start(); await server.start();
} }
module.exports = { load, active }; module.exports = { load, active, fail };
+8 -1
View File
@@ -160,7 +160,13 @@ async function setTestdata(_id, readStream) {
return await edit(_id, { data: f.id }); // eslint-disable-line no-return-await return await edit(_id, { data: f.id }); // eslint-disable-line no-return-await
} }
module.exports = { async function getData(pid) {
const pdoc = await get(pid);
if (!pdoc.data) return null;
return gridfs.openDownloadStream(pdoc.data);
}
global.Hydro.model.problem = module.exports = {
add, add,
inc, inc,
get, get,
@@ -175,4 +181,5 @@ module.exports = {
getMultiStatus, getMultiStatus,
setTestdata, setTestdata,
updateStatus, updateStatus,
getData,
}; };
+2 -2
View File
@@ -21,7 +21,7 @@ function del(_id) {
async function getFirst(query) { async function getFirst(query) {
const res = await coll.find(query).sort('_id', 1).limit(1).toArray(); const res = await coll.find(query).sort('_id', 1).limit(1).toArray();
if (res) { if (res.length) {
await coll.deleteOne({ _id: res[0]._id }); await coll.deleteOne({ _id: res[0]._id });
return res[0]; return res[0];
} }
@@ -35,6 +35,6 @@ async function consume(query, cb) {
}, 100); }, 100);
} }
module.exports = { global.Hydro.model.task = module.exports = {
add, get, del, count, getFirst, consume, add, get, del, count, getFirst, consume,
}; };
+2 -41
View File
@@ -1,59 +1,20 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { defaultsDeep } = require('lodash'); const { defaults } = require('lodash');
let options = { let options = {
db: {
name: 'hydro', name: 'hydro',
host: '127.0.0.1', host: '127.0.0.1',
port: 27017, port: 27017,
username: '', username: '',
password: '', password: '',
},
template: {
path: path.join(process.cwd(), 'templates'),
},
listen: {
host: '127.0.0.1',
port: 8888,
domain: '',
https: false,
},
smtp: {
from: '',
host: '',
port: 465,
user: '',
pass: '',
secure: false,
},
session: {
keys: ['Hydro'],
secure: false,
domain: '127.0.0.1',
saved_expire_seconds: 3600 * 24,
unsaved_expire_seconds: 600,
registration_token_expire_seconds: 600,
},
constants: {
PROBLEM_PER_PAGE: 100,
RECORD_PER_PAGE: 100,
SOLUTION_PER_PAGE: 20,
CONTEST_PER_PAGE: 20,
TRAINING_PER_PAGE: 10,
DISCUSSION_PER_PAGE: 50,
REPLY_PER_PAGE: 50,
CONTESTS_ON_MAIN: 5,
TRAININGS_ON_MAIN: 5,
DISCUSSIONS_ON_MAIN: 20,
},
}; };
let f = path.resolve(process.cwd(), 'config.json'); let f = path.resolve(process.cwd(), 'config.json');
if (!fs.existsSync(f)) f = path.resolve(os.homedir(), '.config', 'hydro', 'config.json'); if (!fs.existsSync(f)) f = path.resolve(os.homedir(), '.config', 'hydro', 'config.json');
if (!fs.existsSync(f)) f = path.resolve('/config/config.json'); if (!fs.existsSync(f)) f = path.resolve('/config/config.json');
const t = JSON.parse(fs.readFileSync(f)); const t = JSON.parse(fs.readFileSync(f));
options = defaultsDeep(t, options); options = defaults(t, options);
module.exports = options; module.exports = options;
+38 -2
View File
@@ -1,14 +1,47 @@
const { defaults } = require('lodash'); const { defaults } = require('lodash');
const db = require('../service/db'); const db = require('../service/db');
const builtin = require('../model/builtin'); const builtin = require('../model/builtin');
const system = require('../model/system');
const pwhash = require('../lib/pwhash'); const pwhash = require('../lib/pwhash');
const { udoc } = require('../interface'); const { udoc } = require('../interface');
const collUser = db.collection('user'); const collUser = db.collection('user');
const collRole = db.collection('role'); const collRole = db.collection('role');
const collSystem = db.collection('system');
async function run() { async function run() {
const def = {
PROBLEM_PER_PAGE: 100,
RECORD_PER_PAGE: 100,
SOLUTION_PER_PAGE: 20,
CONTEST_PER_PAGE: 20,
TRAINING_PER_PAGE: 10,
DISCUSSION_PER_PAGE: 50,
REPLY_PER_PAGE: 50,
CONTESTS_ON_MAIN: 5,
TRAININGS_ON_MAIN: 5,
DISCUSSIONS_ON_MAIN: 20,
'smtp.host': '',
'smtp.port': 465,
'smtp.from': '',
'smtp.user': '',
'smtp.pass': '',
'smtp.secure': false,
'db.ver': 1,
'listen.https': false,
'listen.port': 8888,
'session.keys': ['Hydro'],
'session.secure': false,
'session.saved_expire_seconds': 3600 * 24,
'session.unsaved_expire_seconds': 600,
changemail_token_expire_seconds: 3600 * 24,
registration_token_expire_seconds: 600,
};
const tasks = [];
for (const key in def) {
tasks.push(system.set(key, def[key]));
}
await Promise.all(tasks);
try {
const salt = pwhash.salt(); const salt = pwhash.salt();
await collUser.insertMany([ await collUser.insertMany([
defaults({ defaults({
@@ -39,8 +72,11 @@ async function run() {
role: 'root', role: 'root',
}, udoc), }, udoc),
]); ]);
} catch (e) {
// Ignore user already exist error
}
await collRole.deleteMany({ _id: { $in: builtin.BUILTIN_ROLES.map((role) => role._id) } });
await collRole.insertMany(builtin.BUILTIN_ROLES); await collRole.insertMany(builtin.BUILTIN_ROLES);
console.log('Installed');
} }
module.exports = { run }; module.exports = { run };
+3 -3
View File
@@ -3,15 +3,15 @@ const bus = require('./bus');
const options = require('../options'); const options = require('../options');
let mongourl = 'mongodb://'; let mongourl = 'mongodb://';
if (options.db.username) mongourl += `${options.db.username}:${options.db.password}@`; if (options.username) mongourl += `${options.username}:${options.password}@`;
mongourl += `${options.db.host}:${options.db.port}/${options.db.name}`; mongourl += `${options.host}:${options.port}/${options.name}`;
/** /**
* @type {import('mongodb').Db} * @type {import('mongodb').Db}
*/ */
let db = null; let db = null;
Mongo.MongoClient.connect(mongourl, { useNewUrlParser: true, useUnifiedTopology: true }) Mongo.MongoClient.connect(mongourl, { useNewUrlParser: true, useUnifiedTopology: true })
.then((Database) => { .then((Database) => {
db = Database.db(options.db.name); db = Database.db(options.name);
db.Db = Database; db.Db = Database;
bus.publish('system_database_connected', null); bus.publish('system_database_connected', null);
}); });
+5 -2
View File
@@ -249,7 +249,7 @@ function Route(route, RouteHandler) {
const args = { ...ctx.params, ...ctx.query, ...ctx.request.body }; const args = { ...ctx.params, ...ctx.query, ...ctx.request.body };
if (h.___prepare) await h.___prepare(args); if (h.___prepare) await h.___prepare(args);
try {
for (const l of check) { for (const l of check) {
if (args[l]) { if (args[l]) {
args[l] = new ObjectID(args[l]); args[l] = new ObjectID(args[l]);
@@ -269,7 +269,10 @@ function Route(route, RouteHandler) {
if (args.roles) { if (args.roles) {
for (const i of args.roles) validator.checkRole(i); for (const i of args.roles) validator.checkRole(i);
} }
} catch (e) {
if (e instanceof ValidationError) throw e;
throw new ValidationError('Argument check failed');
}
if (h.__prepare) await h.__prepare(args); if (h.__prepare) await h.__prepare(args);
if (h._prepare) await h._prepare(args); if (h._prepare) await h._prepare(args);
if (h.prepare) await h.prepare(args); if (h.prepare) await h.prepare(args);
-20
View File
@@ -1,20 +0,0 @@
const Mongo = require('mongodb');
const options = require('./options');
async function run() {
let mongourl = 'mongodb://';
if (options.db.username) mongourl += `${options.db.username}:${options.db.password}@`;
mongourl += `${options.db.host}:${options.db.port}/${options.db.name}`;
const Database = await Mongo.MongoClient.connect(mongourl, {
useNewUrlParser: true, useUnifiedTopology: true,
});
const db = Database.db(options.db.name);
await db.dropDatabase();
console.log('Dropped');
process.exit(0);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});
-14
View File
@@ -1,14 +0,0 @@
const fs = require('fs');
const path = require('path');
const child = require('child_process');
const yaml = require('js-yaml');
exports.prebuild = async function prebuild() {
if (!fs.existsSync(path.resolve(__dirname, 'HydroJudger'))) {
const res = child.spawnSync('git clone https://github.com/hydro-dev/HydroJudger.git');
if (res.error) throw res.error;
}
let lang = fs.readFileSync(path.resolve(__dirname, 'langs.yaml')).toString();
lang = yaml.safeLoad(lang);
fs.writeFileSync(path.resolve(__dirname, '__lang.json'), JSON.stringify(lang));
};
-5
View File
@@ -1,5 +0,0 @@
{
"name": "judger",
"version": "1.0.0",
"description": "HydroJudger"
}
-167
View File
@@ -1,167 +0,0 @@
const fs = require('fs');
const path = require('path');
const child = require('child_process');
async function postInit() {
const config = require('./HydroJudger/judger/config');
// eslint-disable-next-line import/no-unresolved
config.LANGS = require('./__langs.json');
const { mkdirp, rmdir, compilerText } = require('./HydroJudger/judger/utils');
const log = require('./HydroJudger/judger/log');
const tmpfs = require('./HydroJudger/judger/tmpfs');
const { FormatError, CompileError, SystemError } = require('./HydroJudger/judger/error');
const { STATUS_COMPILE_ERROR, STATUS_SYSTEM_ERROR } = require('./HydroJudger/judger/status');
const readCases = require('./HydroJudger/judger/cases');
const judger = require('./HydroJudger/judger/judger');
const fsp = fs.promises;
const { problem, task } = global.Hydro.model;
const { judge } = global.Hydro.handler;
async function problemData(pid, savePath) {
const tmpFilePath = path.resolve(config.CACHE_DIR, `download_${pid}`);
const res = await problem.getData(pid);
const w = await fs.createWriteStream(tmpFilePath);
res.data.pipe(w);
await new Promise((resolve, reject) => {
w.on('finish', resolve);
w.on('error', reject);
});
mkdirp(path.dirname(savePath));
await new Promise((resolve, reject) => {
child.exec(`unzip ${tmpFilePath} -d ${savePath}`, (e) => {
if (e) reject(e);
else resolve();
});
});
await fsp.unlink(tmpFilePath);
await this.processData(savePath).catch();
return savePath;
}
async function cacheOpen(pid, version) {
const filePath = path.join(config.CACHE_DIR, pid);
if (fs.existsSync(filePath)) {
let ver;
try {
ver = fs.readFileSync(path.join(filePath, 'version')).toString();
} catch (e) { /* ignore */ }
if (version === ver) return filePath;
rmdir(filePath);
}
mkdirp(filePath);
await problemData(pid, filePath);
fs.writeFileSync(path.join(filePath, 'version'), version);
return filePath;
}
function getNext(that) {
that.nextId = 1;
that.nextWaiting = [];
return (data, id) => {
data.key = 'next';
data.rid = that.rid;
if (id) {
if (id === that.nextId) {
judge.next(data);
that.nextId++;
let t = true;
while (t) {
t = false;
for (const i in that.nextWaiting) {
if (that.nextId === that.nextWaiting[i].id) {
judge.next(that.nextWaiting[i].data);
that.nextId++;
that.nextWaiting.splice(i, 1);
t = true;
}
}
}
} else that.nextWaiting.push({ data, id });
} else judge.next(data);
};
}
function getEnd(rid) {
return (data) => {
data.key = 'end';
data.rid = rid;
log.log({
status: data.status,
score: data.score,
time_ms: data.time_ms,
memory_kb: data.memory_kb,
});
judge.end(data);
};
}
class JudgeTask {
constructor(request) {
this.stat = {};
this.stat.receive = new Date();
this.request = request;
}
async handle() {
try {
this.stat.handle = new Date();
this.event = this.request.event;
this.pid = this.request.pid;
this.rid = this.request.rid;
this.lang = this.request.lang;
this.code = this.request.code;
this.data = this.request.data;
this.next = getNext(this);
this.end = getEnd(this.rid);
this.tmpdir = path.resolve(config.TEMP_DIR, 'tmp', this.rid);
this.clean = [];
mkdirp(this.tmpdir);
tmpfs.mount(this.tmpdir, '64m');
log.submission(`${this.rid}`, { pid: this.pid });
if (!this.event) await this.submission();
else throw new SystemError(`Unsupported type: ${this.type}`);
} catch (e) {
if (e instanceof CompileError) {
this.next({ compiler_text: compilerText(e.stdout, e.stderr) });
this.end({
status: STATUS_COMPILE_ERROR, score: 0, time_ms: 0, memory_kb: 0,
});
} else if (e instanceof FormatError) {
this.next({ judge_text: `${e.message}\n${JSON.stringify(e.params)}` });
this.end({
status: STATUS_SYSTEM_ERROR, score: 0, time_ms: 0, memory_kb: 0,
});
} else {
log.error(e);
this.next({ judge_text: `${e.message}\n${e.stack}\n${JSON.stringify(e.params)}` });
this.end({
status: STATUS_SYSTEM_ERROR, score: 0, time_ms: 0, memory_kb: 0,
});
}
}
// eslint-disable-next-line no-await-in-loop
for (const clean of this.clean) await clean().catch();
tmpfs.umount(this.tmpdir);
await rmdir(this.tmpdir);
}
async submission() {
this.stat.cache_start = new Date();
this.folder = await cacheOpen(this.pid, this.data);
this.stat.read_cases = new Date();
this.config = await readCases(
this.folder,
{ detail: true },
{ next: this.next },
);
this.stat.judge = new Date();
await judger[this.config.type || 'default'].judge(this);
}
}
task.consume({ type: 'judge' }, (t) => {
(new JudgeTask(t)).handle();
});
}
module.exports = { postInit };
+3 -1
View File
@@ -1,6 +1,8 @@
{ {
"name": "sandbox-linux", "name": "sandbox-linux",
"version": "1.0.0", "version": "1.0.0",
"os": "Linux", "os": [
"linux"
],
"description": "Sandbox service for linux" "description": "Sandbox service for linux"
} }
+1 -1
View File
@@ -2,7 +2,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const child = require('child_process'); const child = require('child_process');
const p = child.spawn(path.resolve(os.tmpdir(), 'hydro', 'sandbox-linux-amd64', 'executorserver', ['-silent'])); const p = child.spawn(path.resolve(os.tmpdir(), 'hydro', 'sandbox-linux-amd64', 'executorserver'), ['-silent']);
if (!p.stdout) throw new Error('Cannot start executorserver'); if (!p.stdout) throw new Error('Cannot start executorserver');
else { else {
p.stdout.on('data', (data) => { p.stdout.on('data', (data) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hydro", "name": "hydro",
"version": "0.0.0", "version": "1.0.0-preview",
"main": "hydro/development.js", "main": "hydro/development.js",
"repository": "https://github.com/hydro-dev/Hydro.git", "repository": "https://github.com/hydro-dev/Hydro.git",
"author": "masnn", "author": "masnn",
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html data-page="setup" data-layout="immersive" class="layout--immersive page--setup nojs">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="chrome=1"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="msapplication-TileColor" content="#579e9a">
<meta name="theme-color" content="#56758f">
<link rel="stylesheet" media="all" href="{{ static_url('vj4.css') }}">
<title>{{ _('Setup') }}</title>
<script>
var _htmlNode = document.documentElement;
_htmlNode.className = _htmlNode.className.replace(' nojs', ' hasjs');
</script>
</head>
<body>
<div class="slideout-panel" id="panel">
<div class="main">
<div class="row"><div class="columns">
<div class="immersive--content immersive--center">
<h1>{{ _('Setup') }}</h1>
<div class="text-center">
{{ _('Done. Please restart the program.') }}
</div>
</div>
</div></div>
</div>
</div>
<script type="text/javascript" src="{{ static_url('vj4.js') }}"></script>
</body>
</html>