summaryrefslogtreecommitdiffhomepage
path: root/modules/luci-base/ucode
diff options
context:
space:
mode:
Diffstat (limited to 'modules/luci-base/ucode')
-rw-r--r--modules/luci-base/ucode/controller/admin/index.uc160
-rw-r--r--modules/luci-base/ucode/controller/admin/uci.uc150
-rw-r--r--modules/luci-base/ucode/dispatcher.uc1010
-rw-r--r--modules/luci-base/ucode/http.uc574
-rw-r--r--modules/luci-base/ucode/runtime.uc185
-rw-r--r--modules/luci-base/ucode/sys.uc157
-rw-r--r--modules/luci-base/ucode/template/csrftoken.ut24
-rw-r--r--modules/luci-base/ucode/template/error404.ut14
-rw-r--r--modules/luci-base/ucode/template/error500.ut67
-rw-r--r--modules/luci-base/ucode/template/footer.ut42
-rw-r--r--modules/luci-base/ucode/template/header.ut32
-rw-r--r--modules/luci-base/ucode/template/sysauth.ut74
-rw-r--r--modules/luci-base/ucode/template/view.ut12
-rw-r--r--modules/luci-base/ucode/uhttpd.uc12
-rw-r--r--modules/luci-base/ucode/zoneinfo.uc449
15 files changed, 2962 insertions, 0 deletions
diff --git a/modules/luci-base/ucode/controller/admin/index.uc b/modules/luci-base/ucode/controller/admin/index.uc
new file mode 100644
index 0000000000..f0f7c7fd4d
--- /dev/null
+++ b/modules/luci-base/ucode/controller/admin/index.uc
@@ -0,0 +1,160 @@
+// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+// Licensed to the public under the Apache License 2.0.
+
+import { load_catalog, change_catalog, get_translations } from 'luci.core';
+
+const ubus_types = [
+ null,
+ 'array',
+ 'object',
+ 'string',
+ null, // INT64
+ 'number',
+ null, // INT16,
+ 'boolean',
+ 'double'
+];
+
+
+function ubus_reply(id, data, code, errmsg) {
+ const reply = { jsonrpc: '2.0', id };
+
+ if (errmsg)
+ reply.error = { code, message: errmsg };
+ else if (type(code) == 'object')
+ reply.result = code;
+ else if (data != null)
+ reply.result = [ code, data ];
+ else
+ reply.result = [ code ];
+
+ return reply;
+}
+
+function ubus_access(sid, obj, fun) {
+ return (ubus.call('session', 'access', {
+ ubus_rpc_session: sid,
+ scope: 'ubus',
+ object: obj,
+ function: fun
+ })?.access == true);
+}
+
+function ubus_request(req) {
+ if (type(req?.method) != 'string' || req?.jsonrpc != '2.0' || req?.id == null)
+ return ubus_reply(null, null, -32600, 'Invalid request');
+
+ if (req.method == 'call') {
+ if (type(req?.params) != 'array' || length(req.params) < 3)
+ return ubus_reply(null, null, -32600, 'Invalid parameters');
+
+ let sid = req.params[0],
+ obj = req.params[1],
+ fun = req.params[2],
+ arg = req.params[3] ?? {};
+
+ if (type(arg) != 'object' || exists(arg, 'ubus_rpc_session'))
+ return ubus_reply(req.id, null, -32602, 'Invalid parameters');
+
+ if (sid == '00000000000000000000000000000000' && ctx.authsession)
+ sid = ctx.authsession;
+
+ if (!ubus_access(sid, obj, fun))
+ return ubus_reply(req.id, null, -32002, 'Access denied');
+
+ arg.ubus_rpc_session = sid;
+
+
+ // clear error
+ ubus.error();
+
+ const res = ubus.call(obj, fun, arg);
+
+ return ubus_reply(req.id, res, ubus.error(true) ?? 0);
+ }
+
+ if (req.method == 'list') {
+ if (req?.params == null || (type(req.params) == 'array' && length(req.params) == 0)) {
+ return ubus_reply(req.id, null, ubus.list());
+ }
+ else if (type(req.params) == 'array') {
+ const rv = {};
+
+ for (let param in req.params) {
+ if (type(param) != 'string')
+ return ubus_reply(req.id, null, -32602, 'Invalid parameters');
+
+ for (let m, p in ubus.list(param)?.[0]) {
+ for (let pn, pt in p) {
+ rv[param] ??= {};
+ rv[param][m] ??= {};
+ rv[param][m][pn] = ubus_types[pt] ?? 'unknown';
+ }
+ }
+ }
+
+ return ubus_reply(req.id, null, rv);
+ }
+ else {
+ return ubus_reply(req.id, null, -32602, 'Invalid parameters')
+ }
+ }
+
+ return ubus_reply(req.id, null, -32601, 'Method not found')
+}
+
+
+return {
+ action_ubus: function() {
+ let request;
+
+ try { request = json(http.content()); }
+ catch { request = null; }
+
+ http.prepare_content('application/json; charset=UTF-8');
+
+ if (type(request) == 'object')
+ http.write_json(ubus_request(request));
+ else if (type(request) == 'array')
+ http.write_json(map(request, ubus_request));
+ else
+ http.write_json(ubus_reply(null, null, -32700, 'Parse error'))
+ },
+
+ action_translations: function(reqlang) {
+ if (reqlang != null && reqlang != dispatcher.lang) {
+ load_catalog(reqlang, '/usr/lib/lua/luci/i18n');
+ change_catalog(reqlang);
+ }
+
+ http.prepare_content('application/javascript; charset=UTF-8');
+ http.write('window.TR={');
+
+ get_translations((key, val) => http.write(sprintf('"%08x":%J,', key, val)));
+
+ http.write('};');
+ },
+
+ action_logout: function() {
+ const url = dispatcher.build_url();
+
+ if (ctx.authsession) {
+ ubus.call('session', 'destroy', { ubus_rpc_session: ctx.authsession });
+
+ if (http.getenv('HTTPS') == 'on')
+ http.header('Set-Cookie', `sysauth_https=; expires=Thu, 01 Jan 1970 01:00:00 GMT; path=${url}`);
+
+ http.header('Set-Cookie', `sysauth_http=; expires=Thu, 01 Jan 1970 01:00:00 GMT; path=${url}`);
+ }
+
+ http.redirect(url);
+ },
+
+ action_menu: function() {
+ const session = dispatcher.is_authenticated({ methods: [ 'cookie:sysauth_https', 'cookie:sysauth_http' ] });
+ const menu = dispatcher.menu_json(session?.acls ?? {}) ?? {};
+
+ http.prepare_content('application/json; charset=UTF-8');
+ http.write_json(menu);
+ }
+};
diff --git a/modules/luci-base/ucode/controller/admin/uci.uc b/modules/luci-base/ucode/controller/admin/uci.uc
new file mode 100644
index 0000000000..c38a42b10b
--- /dev/null
+++ b/modules/luci-base/ucode/controller/admin/uci.uc
@@ -0,0 +1,150 @@
+// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+// Licensed to the public under the Apache License 2.0.
+
+import { STATUS_NO_DATA, STATUS_PERMISSION_DENIED } from 'ubus';
+
+let last_ubus_error;
+
+const ubus_error_map = [
+ 200, 'OK',
+ 400, 'Invalid command',
+ 400, 'Invalid argument',
+ 404, 'Method not found',
+ 404, 'Not found',
+ 204, 'No data',
+ 403, 'Permission denied',
+ 504, 'Timeout',
+ 500, 'Not supported',
+ 500, 'Unknown error',
+ 503, 'Connection failed',
+ 500, 'Out of memory',
+ 400, 'Parse error',
+ 500, 'System error',
+];
+
+function ubus_call(object, method, args) {
+ ubus.error(); // clear previous error
+
+ let res = ubus.call(object, method, args);
+
+ last_ubus_error = ubus.error(true);
+
+ return res ?? !last_ubus_error;
+}
+
+function ubus_state_to_http(err) {
+ let code = ubus_error_map[(err << 1) + 0] ?? 200;
+ let msg = ubus_error_map[(err << 1) + 1] ?? 'OK';
+
+ http.status(code, msg);
+
+ if (code != 204) {
+ http.prepare_content('text/plain');
+ http.write(msg);
+ }
+}
+
+function uci_apply(rollback) {
+ if (rollback) {
+ const timeout = +(config?.apply?.rollback ?? 90) || 0;
+ const success = ubus_call('uci', 'apply', {
+ ubus_rpc_session: ctx.authsession,
+ timeout: max(timeout, 90),
+ rollback: true
+ });
+
+ if (success) {
+ const token = dispatcher.randomid(16);
+
+ ubus.call('session', 'set', {
+ ubus_rpc_session: '00000000000000000000000000000000',
+ values: {
+ rollback: {
+ token,
+ session: ctx.authsession,
+ timeout: time() + timeout
+ }
+ }
+ });
+
+ return token;
+ }
+
+ return null;
+ }
+ else {
+ let changes = ubus_call('uci', 'changes', { ubus_rpc_session: ctx.authsession })?.changes;
+
+ for (let config in changes)
+ if (!ubus_call('uci', 'commit', { ubus_rpc_session: ctx.authsession, config }))
+ return false;
+
+ return ubus_call('uci', 'apply', {
+ ubus_rpc_session: ctx.authsession,
+ rollback: false
+ });
+ }
+}
+
+function uci_confirm(token) {
+ const data = ubus.call('session', 'get', {
+ ubus_rpc_session: '00000000000000000000000000000000',
+ keys: [ 'rollback' ]
+ })?.values?.rollback;
+
+ if (type(data?.token) != 'string' || type(data?.session) != 'string' ||
+ type(data?.timeout) != 'int' || data.timeout < time()) {
+ last_ubus_error = STATUS_NO_DATA;
+
+ return false;
+ }
+
+ if (token != data.token) {
+ last_ubus_error = STATUS_PERMISSION_DENIED;
+
+ return false;
+ }
+
+ if (!ubus_call('uci', 'confirm', { ubus_rpc_session: data.session }))
+ return false;
+
+ ubus_call('session', 'set', {
+ ubus_rpc_session: '00000000000000000000000000000000',
+ values: { rollback: {} }
+ });
+
+ return true;
+}
+
+
+return {
+ action_apply_rollback: function() {
+ const token = uci_apply(true);
+
+ if (token) {
+ http.prepare_content('application/json; charset=UTF-8');
+ http.write_json({ token });
+ }
+ else {
+ ubus_state_to_http(last_ubus_error);
+ }
+ },
+
+ action_apply_unchecked: function() {
+ uci_apply(false);
+ ubus_state_to_http(last_ubus_error);
+ },
+
+ action_confirm: function() {
+ uci_confirm(http.formvalue('token'));
+ ubus_state_to_http(last_ubus_error);
+ },
+
+ action_revert: function() {
+ for (let config in ubus_call('uci', 'changes', { ubus_rpc_session: ctx.authsession })?.changes)
+ if (!ubus_call('uci', 'revert', { ubus_rpc_session: ctx.authsession, config }))
+ break;
+
+ ubus_state_to_http(last_ubus_error);
+ }
+};
diff --git a/modules/luci-base/ucode/dispatcher.uc b/modules/luci-base/ucode/dispatcher.uc
new file mode 100644
index 0000000000..8717385be2
--- /dev/null
+++ b/modules/luci-base/ucode/dispatcher.uc
@@ -0,0 +1,1010 @@
+// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+// Licensed to the public under the Apache License 2.0.
+
+import { open, stat, glob, lsdir, unlink, basename } from 'fs';
+import { striptags, entityencode } from 'html';
+import { connect } from 'ubus';
+import { cursor } from 'uci';
+import { rand } from 'math';
+
+import { hash, load_catalog, change_catalog, translate, ntranslate, getuid } from 'luci.core';
+import { revision as luciversion, branch as luciname } from 'luci.version';
+import { default as LuCIRuntime } from 'luci.runtime';
+import { urldecode } from 'luci.http';
+
+let ubus = connect();
+let uci = cursor();
+
+let indexcache = "/tmp/luci-indexcache";
+
+let http, runtime, tree, luabridge;
+
+function error404(msg) {
+ http.status(404, 'Not Found');
+
+ try {
+ runtime.render('error404', { message: msg ?? 'Not found' });
+ }
+ catch {
+ http.header('Content-Type', 'text/plain; charset=UTF-8');
+ http.write(msg ?? 'Not found');
+ }
+
+ return false;
+}
+
+function error500(msg, ex) {
+ if (!http.eoh) {
+ http.status(500, 'Internal Server Error');
+ http.header('Content-Type', 'text/html; charset=UTF-8');
+ }
+
+ try {
+ runtime.render('error500', {
+ title: ex?.type ?? 'Runtime exception',
+ message: replace(
+ msg,
+ /(\s)((\/[A-Za-z0-9_.-]+)+:\d+|\[string "[^"]+"\]:\d+)/g,
+ '$1<code>$2</code>'
+ ),
+ exception: ex
+ });
+ }
+ catch {
+ http.write('<!--]]>--><!--\'>--><!--">-->\n');
+ http.write(`<p>${trim(msg)}</p>\n`);
+
+ if (ex) {
+ http.write(`<p>${trim(ex.message)}</p>\n`);
+ http.write(`<pre>${trim(ex.stacktrace[0].context)}</pre>\n`);
+ }
+ }
+
+ exit(0);
+}
+
+function load_luabridge(optional) {
+ if (luabridge == null) {
+ try {
+ luabridge = require('lua');
+ }
+ catch (ex) {
+ luabridge = false;
+
+ if (!optional)
+ error500('No Lua runtime installed');
+ }
+ }
+
+ return luabridge;
+}
+
+function determine_request_language() {
+ let lang = uci.get('luci', 'main', 'lang') || 'auto';
+
+ if (lang == 'auto') {
+ for (let tag in split(http.getenv('HTTP_ACCEPT_LANGUAGE'), ',')) {
+ tag = split(trim(split(tag, ';')?.[0]), '-');
+
+ if (tag) {
+ let cc = tag[1] ? `${tag[0]}_${lc(tag[1])}` : null;
+
+ if (cc && uci.get('luci', 'languages', cc)) {
+ lang = cc;
+ break;
+ }
+ else if (uci.get('luci', 'languages', tag[0])) {
+ lang = tag[0];
+ break;
+ }
+ }
+ }
+ }
+
+ if (lang == 'auto')
+ lang = 'en';
+ else
+ lang = replace(lang, '_', '-');
+
+ if (load_catalog(lang, '/usr/lib/lua/luci/i18n'))
+ change_catalog(lang);
+
+ return lang;
+}
+
+function determine_version() {
+ let res = { luciname, luciversion };
+
+ for (let f = open("/etc/os-release"), l = f?.read?.("line"); l; l = f.read?.("line")) {
+ let kv = split(l, '=', 2);
+
+ switch (kv[0]) {
+ case 'NAME':
+ res.distname = trim(kv[1], '"\' \n');
+ break;
+
+ case 'VERSION':
+ res.distversion = trim(kv[1], '"\' \n');
+ break;
+
+ case 'HOME_URL':
+ res.disturl = trim(kv[1], '"\' \n');
+ break;
+
+ case 'BUILD_ID':
+ res.distrevision = trim(kv[1], '"\' \n');
+ break;
+ }
+ }
+
+ return res;
+}
+
+function read_jsonfile(path, defval) {
+ let rv;
+
+ try {
+ rv = json(open(path, "r"));
+ }
+ catch (e) {
+ rv = defval;
+ }
+
+ return rv;
+}
+
+function read_cachefile(file, reader) {
+ let euid = getuid(),
+ fstat = stat(file),
+ fuid = fstat?.uid,
+ perm = fstat?.perm;
+
+ if (euid != fuid ||
+ perm?.group_read || perm?.group_write || perm?.group_exec ||
+ perm?.other_read || perm?.other_write || perm?.other_exec)
+ return null;
+
+ return reader(file);
+}
+
+function check_fs_depends(spec) {
+ for (let path, kind in spec) {
+ if (kind == 'directory') {
+ if (!length(lsdir(path)))
+ return false;
+ }
+ else if (kind == 'executable') {
+ let fstat = stat(path);
+
+ if (fstat?.type != 'file' || fstat?.user_exec == false)
+ return false;
+ }
+ else if (kind == 'file') {
+ let fstat = stat(path);
+
+ if (fstat?.type != 'file')
+ return false;
+ }
+ else if (kind == 'absent') {
+ if (stat(path) != null)
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function check_uci_depends_options(conf, s, opts) {
+ if (type(opts) == 'string') {
+ return (s['.type'] == opts);
+ }
+ else if (opts === true) {
+ for (let option, value in s)
+ if (ord(option) != 46)
+ return true;
+ }
+ else if (type(opts) == 'object') {
+ for (let option, value in opts) {
+ let sval = s[option];
+
+ if (type(sval) == 'array') {
+ if (!(value in sval))
+ return false;
+ }
+ else if (value === true) {
+ if (sval == null)
+ return false;
+ }
+ else {
+ if (sval != value)
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+function check_uci_depends_section(conf, sect) {
+ for (let section, options in sect) {
+ let stype = match(section, /^@([A-Za-z0-9_-]+)$/);
+
+ if (stype) {
+ let found = false;
+
+ uci.load(conf);
+ uci.foreach(conf, stype[1], (s) => {
+ if (check_uci_depends_options(conf, s, options)) {
+ found = true;
+ return false;
+ }
+ });
+
+ if (!found)
+ return false;
+ }
+ else {
+ let s = uci.get_all(conf, section);
+
+ if (!s || !check_uci_depends_options(conf, s, options))
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function check_uci_depends(conf) {
+ for (let config, values in conf) {
+ if (values == true) {
+ let found = false;
+
+ uci.load(config);
+ uci.foreach(config, null, () => { found = true });
+
+ if (!found)
+ return false;
+ }
+ else if (type(values) == 'object') {
+ if (!check_uci_depends_section(config, values))
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function check_depends(spec) {
+ if (type(spec?.depends?.fs) in ['array', 'object']) {
+ let satisfied = false;
+ let alternatives = (type(spec.depends.fs) == 'array') ? spec.depends.fs : [ spec.depends.fs ];
+
+ for (let alternative in alternatives) {
+ if (check_fs_depends(alternative)) {
+ satisfied = true;
+ break;
+ }
+ }
+
+ if (!satisfied)
+ return false;
+ }
+
+ if (type(spec?.depends?.uci) in ['array', 'object']) {
+ let satisfied = false;
+ let alternatives = (type(spec.depends.uci) == 'array') ? spec.depends.uci : [ spec.depends.uci ];
+
+ for (let alternative in alternatives) {
+ if (check_uci_depends(alternative)) {
+ satisfied = true;
+ break;
+ }
+ }
+
+ if (!satisfied)
+ return false;
+ }
+
+ return true;
+}
+
+function check_acl_depends(require_groups, groups) {
+ if (length(require_groups)) {
+ let writable = false;
+
+ for (let group in require_groups) {
+ let read = ('read' in groups?.[group]);
+ let write = ('write' in groups?.[group]);
+
+ if (!read && !write)
+ return null;
+
+ if (write)
+ writable = true;
+ }
+
+ return writable;
+ }
+
+ return true;
+}
+
+function hash_filelist(files) {
+ let hashval = 0x1b756362;
+
+ for (let file in files) {
+ let st = stat(file);
+
+ if (st)
+ hashval = hash(sprintf("%x|%x|%x", st.ino, st.mtime, st.size), hashval);
+ }
+
+ return hashval;
+}
+
+function build_pagetree() {
+ let tree = { action: { type: 'firstchild' } };
+
+ let schema = {
+ action: 'object',
+ auth: 'object',
+ cors: 'bool',
+ depends: 'object',
+ order: 'int',
+ setgroup: 'string',
+ setuser: 'string',
+ title: 'string',
+ wildcard: 'bool',
+ firstchild_ineligible: 'bool'
+ };
+
+ let files = glob('/usr/share/luci/menu.d/*.json', '/usr/lib/lua/luci/controller/*.lua', '/usr/lib/lua/luci/controller/*/*.lua');
+ let cachefile;
+
+ if (indexcache) {
+ cachefile = sprintf('%s.%08x.json', indexcache, hash_filelist(files));
+
+ let res = read_cachefile(cachefile, read_jsonfile);
+
+ if (res)
+ return res;
+
+ for (let path in glob(indexcache + '.*.json'))
+ unlink(path);
+ }
+
+ for (let file in files) {
+ let data;
+
+ if (substr(file, -5) == '.json')
+ data = read_jsonfile(file);
+ else if (load_luabridge(true))
+ data = runtime.call('luci.dispatcher', 'process_lua_controller', file);
+ else
+ warn(`Lua controller ${file} present but no Lua runtime installed.\n`);
+
+ if (type(data) == 'object') {
+ for (let path, spec in data) {
+ if (type(spec) == 'object') {
+ let node = tree;
+
+ for (let s in match(path, /[^\/]+/g)) {
+ if (s[0] == '*') {
+ node.wildcard = true;
+ break;
+ }
+
+ node.children ??= {};
+ node.children[s[0]] ??= { satisfied: true };
+ node = node.children[s[0]];
+ }
+
+ if (node !== tree) {
+ for (let k, t in schema)
+ if (type(spec[k]) == t)
+ node[k] = spec[k];
+
+ node.satisfied = check_depends(spec);
+ }
+ }
+ }
+ }
+ }
+
+ if (cachefile) {
+ let fd = open(cachefile, 'w', 0600);
+
+ if (fd) {
+ fd.write(tree);
+ fd.close();
+ }
+ }
+
+ return tree;
+}
+
+function apply_tree_acls(node, acl) {
+ for (let name, spec in node?.children)
+ apply_tree_acls(spec, acl);
+
+ if (node?.depends?.acl) {
+ switch (check_acl_depends(node.depends.acl, acl["access-group"])) {
+ case null: node.satisfied = false; break;
+ case false: node.readonly = true; break;
+ }
+ }
+}
+
+function menu_json(acl) {
+ tree ??= build_pagetree();
+
+ if (acl)
+ apply_tree_acls(tree, acl);
+
+ return tree;
+}
+
+function ctx_append(ctx, name, node) {
+ ctx.path ??= [];
+ push(ctx.path, name);
+
+ ctx.acls ??= [];
+ push(ctx.acls, ...(node?.depends?.acl || []));
+
+ ctx.auth = node.auth || ctx.auth;
+ ctx.cors = node.cors || ctx.cors;
+ ctx.suid = node.setuser || ctx.suid;
+ ctx.sgid = node.setgroup || ctx.sgid;
+
+ return ctx;
+}
+
+function session_retrieve(sid, allowed_users) {
+ let sdat = ubus.call("session", "get", { ubus_rpc_session: sid });
+ let sacl = ubus.call("session", "access", { ubus_rpc_session: sid });
+
+ if (type(sdat?.values?.token) == 'string' &&
+ (!length(allowed_users) || sdat?.values?.username in allowed_users)) {
+ // uci:set_session_id(sid)
+ return {
+ sid,
+ data: sdat.values,
+ acls: length(sacl) ? sacl : {}
+ };
+ }
+
+ return null;
+}
+
+function randomid(num_bytes) {
+ let bytes = [];
+
+ while (num_bytes-- > 0)
+ push(bytes, sprintf('%02x', rand() % 256));
+
+ return join('', bytes);
+}
+
+function syslog(prio, msg) {
+ warn(sprintf("[%s] %s\n", prio, msg));
+}
+
+function session_setup(user, pass, path) {
+ let timeout = uci.get('luci', 'sauth', 'sessiontime');
+ let login = ubus.call("session", "login", {
+ username: user,
+ password: pass,
+ timeout: timeout ? +timeout : null
+ });
+
+ if (type(login?.ubus_rpc_session) == 'string') {
+ ubus.call("session", "set", {
+ ubus_rpc_session: login.ubus_rpc_session,
+ values: { token: randomid(16) }
+ });
+ syslog("info", sprintf("luci: accepted login on /%s for %s from %s",
+ join('/', path), user || "?", http.getenv("REMOTE_ADDR") || "?"));
+
+ return session_retrieve(login.ubus_rpc_session);
+ }
+
+ syslog("info", sprintf("luci: failed login on /%s for %s from %s",
+ join('/', path), user || "?", http.getenv("REMOTE_ADDR") || "?"));
+}
+
+function check_authentication(method) {
+ let m = match(method, /^([[:alpha:]]+):(.+)$/);
+ let sid;
+
+ switch (m?.[1]) {
+ case 'cookie':
+ sid = http.getcookie(m[2]);
+ break;
+
+ case 'param':
+ sid = http.formvalue(m[2]);
+ break;
+
+ case 'query':
+ sid = http.formvalue(m[2], true);
+ break;
+ }
+
+ return sid ? session_retrieve(sid) : null;
+}
+
+function is_authenticated(auth) {
+ for (let method in auth?.methods) {
+ let session = check_authentication(method);
+
+ if (session)
+ return session;
+ }
+
+ return null;
+}
+
+function node_weight(node) {
+ let weight = min(node.order ?? 9999, 9999);
+
+ if (node.auth?.login)
+ weight += 10000;
+
+ return weight;
+}
+
+function clone(src) {
+ switch (type(src)) {
+ case 'array':
+ return map(src, clone);
+
+ case 'object':
+ let dest = {};
+
+ for (let k, v in src)
+ dest[k] = clone(v);
+
+ return dest;
+
+ default:
+ return src;
+ }
+}
+
+function resolve_firstchild(node, session, login_allowed, ctx) {
+ let candidate, candidate_ctx;
+
+ for (let name, child in node.children) {
+ if (!child.satisfied)
+ continue;
+
+ if (!session)
+ session = is_authenticated(node.auth);
+
+ let cacl = child.depends?.acl;
+ let login = login_allowed || child.auth?.login;
+
+ if (login || check_acl_depends(cacl, session?.acls?.["access-group"]) != null) {
+ if (child.title && type(child.action) == "object") {
+ let child_ctx = ctx_append(clone(ctx), name, child);
+ if (child.action.type == "firstchild") {
+ if (!candidate || node_weight(candidate) > node_weight(child)) {
+ let have_grandchild = resolve_firstchild(child, session, login, child_ctx);
+ if (have_grandchild) {
+ candidate = child;
+ candidate_ctx = child_ctx;
+ }
+ }
+ }
+ else if (!child.firstchild_ineligible) {
+ if (!candidate || node_weight(candidate) > node_weight(child)) {
+ candidate = child;
+ candidate_ctx = child_ctx;
+ }
+ }
+ }
+ }
+ }
+
+ if (!candidate)
+ return false;
+
+ for (let k, v in candidate_ctx)
+ ctx[k] = v;
+
+ return true;
+}
+
+function resolve_page(tree, request_path) {
+ let node = tree;
+ let login = false;
+ let session = null;
+ let ctx = {};
+
+ for (let i, s in request_path) {
+ node = node.children?.[s];
+
+ if (!node?.satisfied)
+ break;
+
+ ctx_append(ctx, s, node);
+
+ if (!session)
+ session = is_authenticated(node.auth);
+
+ if (!login && node.auth?.login)
+ login = true;
+
+ if (node.wildcard) {
+ ctx.request_args = [];
+ ctx.request_path = ctx.path ? [ ...ctx.path ] : [];
+
+ while (++i < length(request_path)) {
+ push(ctx.request_path, request_path[i]);
+ push(ctx.request_args, request_path[i]);
+ }
+
+ break;
+ }
+ }
+
+ if (node?.action?.type == 'firstchild')
+ resolve_firstchild(node, session, login, ctx);
+
+ ctx.acls ??= {};
+ ctx.path ??= [];
+ ctx.request_args ??= [];
+ ctx.request_path ??= request_path ? [ ...request_path ] : [];
+
+ ctx.authsession = session?.sid;
+ ctx.authtoken = session?.data?.token;
+ ctx.authuser = session?.data?.username;
+ ctx.authacl = session?.acls;
+
+ node = tree;
+
+ for (let s in ctx.path) {
+ node = node.children[s];
+ assert(node, "Internal node resolve error");
+ }
+
+ return { node, ctx, session };
+}
+
+function require_post_security(target, args) {
+ if (target?.type == 'arcombine')
+ return require_post_security(length(args) ? target?.targets?.[1] : target?.targets?.[0], args);
+
+ if (type(target?.post) == 'object') {
+ for (let param_name, required_val in target.post) {
+ let request_val = http.formvalue(param_name);
+
+ if ((type(required_val) == 'string' && request_val != required_val) ||
+ (required_val == true && request_val == null))
+ return false;
+ }
+
+ return true;
+ }
+
+ return (target?.post == true);
+}
+
+function test_post_security(authtoken) {
+ if (http.getenv("REQUEST_METHOD") != "POST") {
+ http.status(405, "Method Not Allowed");
+ http.header("Allow", "POST");
+
+ return false;
+ }
+
+ if (http.formvalue("token") != authtoken) {
+ http.status(403, "Forbidden");
+ runtime.render("csrftoken");
+
+ return false;
+ }
+
+ return true;
+}
+
+function build_url(...path) {
+ let url = [ http.getenv('SCRIPT_NAME') ?? '' ];
+
+ for (let p in path)
+ if (match(p, /^[A-Za-z0-9_%.\/,;-]+$/))
+ push(url, '/', p);
+
+ if (length(url) == 1)
+ push(url, '/');
+
+ return join('', url);
+}
+
+function lookup(...segments) {
+ let node = menu_json();
+ let path = [];
+
+ for (let segment in segments)
+ for (let name in split(segment, '/'))
+ push(path, name);
+
+ for (let name in path) {
+ node = node.children[name];
+
+ if (!node)
+ return null;
+
+ if (node.leaf)
+ break;
+ }
+
+ return { node, url: build_url(...path) };
+}
+
+function rollback_pending() {
+ const now = time();
+ const rv = ubus.call('session', 'get', {
+ ubus_rpc_session: '00000000000000000000000000000000',
+ keys: [ 'rollback' ]
+ });
+
+ if (type(rv?.values?.rollback?.token) != 'string' ||
+ type(rv?.values?.rollback?.session) != 'string' ||
+ type(rv?.values?.rollback?.timeout) != 'int' ||
+ rv.values.rollback.timeout <= now)
+ return false;
+
+ return {
+ remaining: rv.values.rollback.timeout - now,
+ session: rv.values.rollback.session,
+ token: rv.values.rollback.token
+ };
+}
+
+let dispatch;
+
+function render_action(fn) {
+ const data = render(fn);
+
+ http.write_headers();
+ http.output(data);
+}
+
+function run_action(request_path, lang, tree, resolved, action) {
+ switch ((type(action) == 'object') ? action.type : 'none') {
+ case 'template':
+ if (runtime.is_ucode_template(action.path))
+ runtime.render(action.path, {});
+ else
+ render_action(() => {
+ runtime.call('luci.dispatcher', 'render_lua_template', action.path);
+ });
+ break;
+
+ case 'view':
+ runtime.render('view', { view: action.path });
+ break;
+
+ case 'call':
+ render_action(() => {
+ runtime.call(action.module, action.function,
+ ...(action.parameters ?? []),
+ ...resolved.ctx.request_args
+ );
+ });
+ break;
+
+ case 'function':
+ const mod = require(action.module);
+
+ assert(type(mod[action.function]) == 'function',
+ `Module '${action.module}' does not export function '${action.function}'`);
+
+ render_action(() => {
+ call(mod[action.function], mod, runtime.env,
+ ...(action.parameters ?? []),
+ ...resolved.ctx.request_args
+ );
+ });
+ break;
+
+ case 'cbi':
+ render_action(() => {
+ runtime.call('luci.dispatcher', 'invoke_cbi_action',
+ action.path, null,
+ ...resolved.ctx.request_args
+ );
+ });
+ break;
+
+ case 'form':
+ render_action(() => {
+ runtime.call('luci.dispatcher', 'invoke_form_action',
+ action.path,
+ ...resolved.ctx.request_args
+ );
+ });
+ break;
+
+ case 'alias':
+ dispatch(http, [ ...split(action.path, '/'), ...resolved.ctx.request_args ]);
+ break;
+
+ case 'rewrite':
+ dispatch(http, [
+ ...splice([ ...request_path ], 0, action.remove),
+ ...split(action.path, '/'),
+ ...resolved.ctx.request_args
+ ]);
+ break;
+
+ case 'firstchild':
+ if (!length(tree.children)) {
+ error404("No root node was registered, this usually happens if no module was installed.\n" +
+ "Install luci-mod-admin-full and retry. " +
+ "If the module is already installed, try removing the /tmp/luci-indexcache file.");
+ break;
+ }
+
+ /* fall through */
+
+ case 'none':
+ error404(`No page is registered at '/${entityencode(join("/", resolved.ctx.request_path))}'.\n` +
+ "If this url belongs to an extension, make sure it is properly installed.\n" +
+ "If the extension was recently installed, try removing the /tmp/luci-indexcache file.");
+ break;
+
+ default:
+ error500(`Unhandled action type ${action?.type ?? '?'}`);
+ }
+}
+
+dispatch = function(_http, path) {
+ http = _http;
+
+ let version = determine_version();
+ let lang = determine_request_language();
+
+ runtime = runtime || LuCIRuntime({
+ http,
+ ubus,
+ uci,
+ ctx: {},
+ version,
+ config: {
+ main: uci.get_all('luci', 'main') ?? {},
+ apply: uci.get_all('luci', 'apply') ?? {}
+ },
+ dispatcher: {
+ rollback_pending,
+ is_authenticated,
+ load_luabridge,
+ lookup,
+ menu_json,
+ build_url,
+ randomid,
+ error404,
+ error500,
+ lang
+ },
+ striptags,
+ entityencode,
+ _: (...args) => translate(...args) ?? args[0],
+ N_: (...args) => ntranslate(...args) ?? (args[0] == 1 ? args[1] : args[2]),
+ });
+
+ try {
+ let menu = menu_json();
+
+ path ??= map(match(http.getenv('PATH_INFO'), /[^\/]+/g), m => urldecode(m[0]));
+
+ let resolved = resolve_page(menu, path);
+
+ runtime.env.ctx = resolved.ctx;
+ runtime.env.dispatched = resolved.node;
+ runtime.env.requested ??= resolved.node;
+
+ if (length(resolved.ctx.auth)) {
+ let session = is_authenticated(resolved.ctx.auth);
+
+ if (!session && resolved.ctx.auth.login) {
+ let user = http.getenv('HTTP_AUTH_USER');
+ let pass = http.getenv('HTTP_AUTH_PASS');
+
+ if (user == null && pass == null) {
+ user = http.formvalue('luci_username');
+ pass = http.formvalue('luci_password');
+ }
+
+ if (user != null && pass != null)
+ session = session_setup(user, pass, resolved.ctx.request_path);
+
+ if (!session) {
+ resolved.ctx.path = [];
+
+ http.status(403, 'Forbidden');
+ http.header('X-LuCI-Login-Required', 'yes');
+
+ let scope = { duser: 'root', fuser: user };
+ let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`;
+
+ if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_sysauth)) {
+ try {
+ return runtime.render(theme_sysauth, scope);
+ }
+ catch (e) {
+ runtime.env.media_error = `${e}`;
+ }
+ }
+
+ return runtime.render('sysauth', scope);
+ }
+
+ let cookie_name = (http.getenv('HTTPS') == 'on') ? 'sysauth_https' : 'sysauth_http',
+ cookie_secure = (http.getenv('HTTPS') == 'on') ? '; secure' : '';
+
+ http.header('Set-Cookie', `${cookie_name}=${session.sid}; path=${build_url()}; SameSite=strict; HttpOnly${cookie_secure}`);
+ http.redirect(build_url(...resolved.ctx.request_path));
+
+ return;
+ }
+
+ if (!session) {
+ http.status(403, 'Forbidden');
+ http.header('X-LuCI-Login-Required', 'yes');
+
+ return;
+ }
+
+ resolved.ctx.authsession ??= session.sid;
+ resolved.ctx.authtoken ??= session.data?.token;
+ resolved.ctx.authuser ??= session.data?.username;
+ resolved.ctx.authacl ??= session.acls;
+
+ /* In case the Lua runtime was already initialized, e.g. by probing legacy
+ * theme header templates, make sure to update the session ID of the uci
+ * module. */
+ if (runtime.L) {
+ runtime.L.invoke('require', 'luci.model.uci');
+ runtime.L.get('luci', 'model', 'uci').invoke('set_session_id', session.sid);
+ }
+ }
+
+ if (length(resolved.ctx.acls)) {
+ let perm = check_acl_depends(resolved.ctx.acls, resolved.ctx.authacl?.['access-group']);
+
+ if (perm == null) {
+ http.status(403, 'Forbidden');
+
+ return;
+ }
+
+ if (resolved.node)
+ resolved.node.readonly = !perm;
+ }
+
+ let action = resolved.node.action;
+
+ if (action?.type == 'arcombine')
+ action = length(resolved.ctx.request_args) ? action.targets?.[1] : action.targets?.[0];
+
+ if (resolved.ctx.cors && http.getenv('REQUEST_METHOD') == 'OPTIONS') {
+ http.status(200, 'OK');
+ http.header('Access-Control-Allow-Origin', http.getenv('HTTP_ORIGIN') ?? '*');
+ http.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+
+ return;
+ }
+
+ if (require_post_security(action) && !test_post_security(resolved.ctx.authtoken))
+ return;
+
+ run_action(path, lang, menu, resolved, action);
+ }
+ catch (ex) {
+ error500('Unhandled exception during request dispatching', ex);
+ }
+};
+
+export default dispatch;
diff --git a/modules/luci-base/ucode/http.uc b/modules/luci-base/ucode/http.uc
new file mode 100644
index 0000000000..e7f64ae6e9
--- /dev/null
+++ b/modules/luci-base/ucode/http.uc
@@ -0,0 +1,574 @@
+// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+// Licensed to the public under the Apache License 2.0.
+
+import {
+ urlencode as _urlencode,
+ urldecode as _urldecode,
+ urlencoded_parser, multipart_parser, header_attribute,
+ ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS
+} from 'lucihttp';
+
+import {
+ error as fserror,
+ stdin, stdout, mkstemp
+} from 'fs';
+
+// luci.http module scope
+export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size
+
+// Decode a mime encoded http message body with multipart/form-data
+// Content-Type. Stores all extracted data associated with its parameter name
+// in the params table within the given message object. Multiple parameter
+// values are stored as tables, ordinary ones as strings.
+// If an optional file callback function is given then it is fed with the
+// file contents chunk by chunk and only the extracted file name is stored
+// within the params table. The callback function will be called subsequently
+// with three arguments:
+// o Table containing decoded (name, file) and raw (headers) mime header data
+// o String value containing a chunk of the file data
+// o Boolean which indicates whether the current chunk is the last one (eof)
+export function mimedecode_message_body(src, msg, file_cb) {
+ let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
+ let err, header, field, parser;
+
+ parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) {
+ if (what == parser.PART_INIT) {
+ field = {};
+ }
+ else if (what == parser.HEADER_NAME) {
+ header = lc(buffer);
+ }
+ else if (what == parser.HEADER_VALUE && header) {
+ if (lc(header) == 'content-disposition' &&
+ header_attribute(buffer, null) == 'form-data') {
+ field.name = header_attribute(buffer, 'name');
+ field.file = header_attribute(buffer, 'filename');
+ field[1] = field.file;
+ }
+
+ field.headers = field.headers || {};
+ field.headers[header] = buffer;
+ }
+ else if (what == parser.PART_BEGIN) {
+ return !field.file;
+ }
+ else if (what == parser.PART_DATA && field.name && length > 0) {
+ if (field.file) {
+ if (file_cb) {
+ file_cb(field, buffer, false);
+
+ msg.params[field.name] = msg.params[field.name] || field;
+ }
+ else {
+ if (!field.fd)
+ field.fd = mkstemp(field.name);
+
+ if (field.fd) {
+ field.fd.write(buffer);
+ msg.params[field.name] = msg.params[field.name] || field;
+ }
+ }
+ }
+ else {
+ field.value = buffer;
+ }
+ }
+ else if (what == parser.PART_END && field.name) {
+ if (field.file && msg.params[field.name]) {
+ if (file_cb)
+ file_cb(field, '', true);
+ else if (field.fd)
+ field.fd.seek(0);
+ }
+ else {
+ let val = msg.params[field.name];
+
+ if (type(val) == 'array')
+ push(val, field.value || '');
+ else if (val != null)
+ msg.params[field.name] = [ val, field.value || '' ];
+ else
+ msg.params[field.name] = field.value || '';
+ }
+
+ field = null;
+ }
+ else if (what == parser.ERROR) {
+ err = buffer;
+ }
+
+ return true;
+ }, HTTP_MAX_CONTENT);
+
+ while (true) {
+ let chunk = src();
+
+ len += length(chunk);
+
+ if (maxlen && len > maxlen + 2)
+ die('Message body size exceeds Content-Length');
+
+ if (!parser.parse(chunk))
+ die(err);
+
+ if (chunk == null)
+ break;
+ }
+};
+
+// Decode an urlencoded http message body with application/x-www-urlencoded
+// Content-Type. Stores all extracted data associated with its parameter name
+// in the params table within the given message object. Multiple parameter
+// values are stored as tables, ordinary ones as strings.
+export function urldecode_message_body(src, msg) {
+ let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
+ let err, name, value, parser;
+
+ parser = urlencoded_parser(function (what, buffer, length) {
+ if (what == parser.TUPLE) {
+ name = null;
+ value = null;
+ }
+ else if (what == parser.NAME) {
+ name = _urldecode(buffer, DECODE_PLUS);
+ }
+ else if (what == parser.VALUE && name) {
+ let val = msg.params[name];
+
+ if (type(val) == 'array')
+ push(val, _urldecode(buffer, DECODE_PLUS) || '');
+ else if (val != null)
+ msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ];
+ else
+ msg.params[name] = _urldecode(buffer, DECODE_PLUS) || '';
+ }
+ else if (what == parser.ERROR) {
+ err = buffer;
+ }
+
+ return true;
+ }, HTTP_MAX_CONTENT);
+
+ while (true) {
+ let chunk = src();
+
+ len += length(chunk);
+
+ if (maxlen && len > maxlen + 2)
+ die('Message body size exceeds Content-Length');
+
+ if (!parser.parse(chunk))
+ die(err);
+
+ if (chunk == null)
+ break;
+ }
+};
+
+// This function will examine the Content-Type within the given message object
+// to select the appropriate content decoder.
+// Currently the application/x-www-urlencoded and application/form-data
+// mime types are supported. If the encountered content encoding can't be
+// handled then the whole message body will be stored unaltered as 'content'
+// property within the given message object.
+export function parse_message_body(src, msg, filecb) {
+ if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') {
+ let ctype = header_attribute(msg.env.CONTENT_TYPE, null);
+
+ // Is it multipart/mime ?
+ if (ctype == 'multipart/form-data')
+ return mimedecode_message_body(src, msg, filecb);
+
+ // Is it application/x-www-form-urlencoded ?
+ else if (ctype == 'application/x-www-form-urlencoded')
+ return urldecode_message_body(src, msg);
+
+ // Unhandled encoding
+ // If a file callback is given then feed it chunk by chunk, else
+ // store whole buffer in message.content
+ let sink;
+
+ // If we have a file callback then feed it
+ if (type(filecb) == 'function') {
+ let meta = {
+ name: 'raw',
+ encoding: msg.env.CONTENT_TYPE
+ };
+
+ sink = (chunk) => {
+ if (chunk != null)
+ return filecb(meta, chunk, false);
+ else
+ return filecb(meta, null, true);
+ };
+ }
+
+ // ... else append to .content
+ else {
+ let chunks = [], len = 0;
+
+ sink = (chunk) => {
+ len += length(chunk);
+
+ if (len > HTTP_MAX_CONTENT)
+ die('POST data exceeds maximum allowed length');
+
+ if (chunk != null) {
+ push(chunks, chunk);
+ }
+ else {
+ msg.content = join('', chunks);
+ msg.content_length = len;
+ }
+ };
+ }
+
+ // Pump data...
+ while (true) {
+ let chunk = src();
+
+ sink(chunk);
+
+ if (chunk == null)
+ break;
+ }
+
+ return true;
+ }
+
+ return false;
+};
+
+export function build_querystring(q) {
+ let s = [];
+
+ for (let k, v in q) {
+ push(s,
+ length(s) ? '&' : '?',
+ _urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k,
+ '=',
+ _urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v
+ );
+ }
+
+ return join('', s);
+};
+
+export function urlencode(value) {
+ if (value == null)
+ return null;
+
+ value = '' + value;
+
+ return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value;
+};
+
+export function urldecode(value, decode_plus) {
+ if (value == null)
+ return null;
+
+ value = '' + value;
+
+ return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value;
+};
+
+// Extract and split urlencoded data pairs, separated bei either "&" or ";"
+// from given url or string. Returns a table with urldecoded values.
+// Simple parameters are stored as string values associated with the parameter
+// name within the table. Parameters with multiple values are stored as array
+// containing the corresponding values.
+export function urldecode_params(url, tbl) {
+ let parser, name, value;
+ let params = tbl || {};
+
+ parser = urlencoded_parser(function(what, buffer, length) {
+ if (what == parser.TUPLE) {
+ name = null;
+ value = null;
+ }
+ else if (what == parser.NAME) {
+ name = _urldecode(buffer);
+ }
+ else if (what == parser.VALUE && name) {
+ params[name] = _urldecode(buffer) || '';
+ }
+
+ return true;
+ });
+
+ if (parser) {
+ let m = match(('' + (url || '')), /[^?]*$/);
+
+ parser.parse(m ? m[0] : '');
+ parser.parse(null);
+ }
+
+ return params;
+};
+
+// Encode each key-value-pair in given table to x-www-urlencoded format,
+// separated by '&'. Tables are encoded as parameters with multiple values by
+// repeating the parameter name with each value.
+export function urlencode_params(tbl) {
+ let enc = [];
+
+ for (let k, v in tbl) {
+ if (type(v) == 'array') {
+ for (let v2 in v) {
+ if (length(enc))
+ push(enc, '&');
+
+ push(enc,
+ _urlencode(k),
+ '=',
+ _urlencode('' + v2));
+ }
+ }
+ else {
+ if (length(enc))
+ push(enc, '&');
+
+ push(enc,
+ _urlencode(k),
+ '=',
+ _urlencode('' + v));
+ }
+ }
+
+ return join(enc, '');
+};
+
+
+// Default IO routines suitable for CGI invocation
+let avail_len = +getenv('CONTENT_LENGTH');
+
+const default_source = () => {
+ let rlen = min(avail_len, 4096);
+
+ if (rlen == 0) {
+ stdin.close();
+
+ return null;
+ }
+
+ let chunk = stdin.read(rlen);
+
+ if (chunk == null)
+ die(`Input read error: ${fserror()}`);
+
+ avail_len -= length(chunk);
+
+ return chunk;
+};
+
+const default_sink = (...chunks) => {
+ for (let chunk in chunks)
+ stdout.write(chunk);
+
+ stdout.flush();
+};
+
+const Class = {
+ formvalue: function(name, noparse) {
+ if (!noparse && !this.parsed_input)
+ this._parse_input();
+
+ if (name != null)
+ return this.message.params[name];
+ else
+ return this.message.params;
+ },
+
+ formvaluetable: function(prefix) {
+ let vals = {};
+
+ prefix = (prefix || '') + '.';
+
+ if (!this.parsed_input)
+ this._parse_input();
+
+ for (let k, v in this.message.params)
+ if (index(k, prefix) == 0)
+ vals[substr(k, length(prefix))] = '' + v;
+
+ return vals;
+ },
+
+ content: function() {
+ if (!this.parsed_input)
+ this._parse_input();
+
+ return this.message.content;
+ },
+
+ getcookie: function(name) {
+ return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name);
+ },
+
+ getenv: function(name) {
+ if (name != null)
+ return this.message.env[name];
+ else
+ return this.message.env;
+ },
+
+ setfilehandler: function(callback) {
+ if (type(callback) == 'resource' && type(callback.call) == 'function')
+ this.filehandler = (...args) => callback.call(...args);
+ else if (type(callback) == 'function')
+ this.filehandler = callback;
+ else
+ die('Invalid callback argument for setfilehandler()');
+
+ if (!this.parsed_input)
+ return;
+
+ // If input has already been parsed then uploads are stored as unlinked
+ // temporary files pointed to by open file handles in the parameter
+ // value table. Loop all params, and invoke the file callback for any
+ // param with an open file handle.
+ for (let name, value in this.message.params) {
+ while (value?.fd) {
+ let data = value.fd.read(1024);
+ let eof = (length(data) == 0);
+
+ this.filehandler(value, data, eof);
+
+ if (eof) {
+ value.fd.close();
+ value.fd = null;
+ }
+ }
+ }
+ },
+
+ _parse_input: function() {
+ parse_message_body(
+ this.input,
+ this.message,
+ this.filehandler
+ );
+
+ this.parsed_input = true;
+ },
+
+ close: function() {
+ this.write_headers();
+ this.closed = true;
+ },
+
+ header: function(key, value) {
+ this.headers ??= {};
+ this.headers[lc(key)] = value;
+ },
+
+ prepare_content: function(mime) {
+ if (!this.headers?.['content-type']) {
+ if (mime == 'application/xhtml+xml') {
+ if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) {
+ mime = 'text/html; charset=UTF-8';
+ this.header('Vary', 'Accept');
+ }
+ }
+
+ this.header('Content-Type', mime);
+ }
+ },
+
+ status: function(code, message) {
+ this.status_code = code ?? 200;
+ this.status_message = message ?? 'OK';
+ },
+
+ write_headers: function() {
+ if (this.eoh)
+ return;
+
+ if (!this.status_code)
+ this.status();
+
+ if (!this.headers?.['content-type'])
+ this.header('Content-Type', 'text/html; charset=UTF-8');
+
+ if (!this.headers?.['cache-control']) {
+ this.header('Cache-Control', 'no-cache');
+ this.header('Expires', '0');
+ }
+
+ if (!this.headers?.['x-frame-options'])
+ this.header('X-Frame-Options', 'SAMEORIGIN');
+
+ if (!this.headers?.['x-xss-protection'])
+ this.header('X-XSS-Protection', '1; mode=block');
+
+ if (!this.headers?.['x-content-type-options'])
+ this.header('X-Content-Type-Options', 'nosniff');
+
+ this.output('Status: ');
+ this.output(this.status_code);
+ this.output(' ');
+ this.output(this.status_message);
+ this.output('\r\n');
+
+ for (let k, v in this.headers) {
+ this.output(k);
+ this.output(': ');
+ this.output(v);
+ this.output('\r\n');
+ }
+
+ this.output('\r\n');
+
+ this.eoh = true;
+ },
+
+ // If the content chunk is nil this function will automatically invoke close.
+ write: function(content) {
+ if (content != null && !this.closed) {
+ this.write_headers();
+ this.output(content);
+
+ return true;
+ }
+ else {
+ this.close();
+ }
+ },
+
+ redirect: function(url) {
+ this.status(302, 'Found');
+ this.header('Location', url ?? '/');
+ this.close();
+ },
+
+ write_json: function(value) {
+ this.write(sprintf('%.J', value));
+ },
+
+ urlencode,
+ urlencode_params,
+
+ urldecode,
+ urldecode_params,
+
+ build_querystring
+};
+
+export default function(env, sourcein, sinkout) {
+ return proto({
+ input: sourcein ?? default_source,
+ output: sinkout ?? default_sink,
+
+ // File handler nil by default to let .content() work
+ file: null,
+
+ // HTTP-Message table
+ message: {
+ env,
+ headers: {},
+ params: urldecode_params(env?.QUERY_STRING ?? '')
+ },
+
+ parsed_input: false
+ }, Class);
+};
diff --git a/modules/luci-base/ucode/runtime.uc b/modules/luci-base/ucode/runtime.uc
new file mode 100644
index 0000000000..f14bf74480
--- /dev/null
+++ b/modules/luci-base/ucode/runtime.uc
@@ -0,0 +1,185 @@
+// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+// Licensed to the public under the Apache License 2.0.
+
+import { access, basename } from 'fs';
+import { cursor } from 'uci';
+
+const template_directory = '/usr/share/ucode/luci/template';
+
+function cut_message(msg) {
+ return trim(replace(msg, /\n--\n.*$/, ''));
+}
+
+function format_nested_exception(ex) {
+ let msg = replace(cut_message(ex.message), /(\n+( \|[^\n]*(\n|$))+)/, (m, m1) => {
+ m1 = replace(m1, /(^|\n) \| ?/g, '$1');
+ m = match(m1, /^(.+?)\n(In.*line \d+, byte \d+:.+)$/);
+
+ return `
+ <div class="exception">
+ <div class="message">${cut_message(m ? m[1] : m1)}</div>
+ ${m ? `<pre class="context">${trim(m[2])}</pre>` : ''}
+ </div>
+ `;
+ });
+
+ return `
+ <div class="exception">
+ <div class="message">${cut_message(msg)}</div>
+ <pre class="context">${trim(ex.stacktrace[0].context)}</pre>
+ </div>
+ `;
+}
+
+function format_lua_exception(ex) {
+ let m = match(ex.message, /^(.+)\nstack traceback:\n(.+)$/);
+
+ return `
+ <div class="exception">
+ <div class="message">${cut_message(m ? m[1] : ex.message)}</div>
+ <pre class="context">${m ? trim(replace(m[2], /(^|\n)\t/g, '$1')) : ex.stacktrace[0].context}</pre>
+ </div>
+ `;
+}
+
+const Class = {
+ init_lua: function(optional) {
+ if (!this.L) {
+ let bridge = this.env.dispatcher.load_luabridge(optional);
+
+ if (bridge) {
+ let http = this.env.http;
+
+ this.L = bridge.create();
+ this.L.set('L', proto({ write: (...args) => http.closed || print(...args) }, this.env));
+ this.L.invoke('require', 'luci.ucodebridge');
+
+ this.env.lua_active = true;
+ }
+ }
+
+ return this.L;
+ },
+
+ is_ucode_template: function(path) {
+ return access(`${template_directory}/${path}.ut`);
+ },
+
+ is_lua_template: function(path) {
+ let vm = this.init_lua(true);
+
+ return vm && access(`${vm.get('_G', 'luci', 'template', 'viewdir')}/${path}.htm`);
+ },
+
+ render_ucode: function(path, scope) {
+ let tmplfunc = loadfile(path, { raw_mode: false });
+
+ if (this.env.http.closed)
+ render(call, tmplfunc, null, scope ?? {});
+ else
+ call(tmplfunc, null, scope ?? {});
+ },
+
+ render_lua: function(path, scope) {
+ let vm = this.init_lua();
+ let render = vm.get('_G', 'luci', 'ucodebridge', 'render');
+
+ render.call(path, scope ?? {});
+ },
+
+ trycompile: function(path) {
+ let ucode_path = `${template_directory}/${path}.ut`;
+
+ if (access(ucode_path)) {
+ try {
+ loadfile(ucode_path, { raw_mode: false });
+ }
+ catch (ucode_err) {
+ return `Unable to compile '${path}' as ucode template: ${format_nested_exception(ucode_err)}`;
+ }
+ }
+ else {
+ try {
+ let vm = this.init_lua(true);
+
+ if (vm)
+ vm.get('_G', 'luci', 'ucodebridge', 'compile').call(path);
+ else
+ return `Unable to compile '${path}' as Lua template: Unable to load Lua runtime`;
+ }
+ catch (lua_err) {
+ return `Unable to compile '${path}' as Lua template: ${format_lua_exception(lua_err)}`;
+ }
+ }
+
+ return true;
+ },
+
+ render_any: function(path, scope) {
+ let ucode_path = `${template_directory}/${path}.ut`;
+
+ scope = proto(scope ?? {}, this.scopes[-1]);
+
+ push(this.scopes, scope);
+
+ try {
+ if (access(ucode_path))
+ this.render_ucode(ucode_path, scope);
+ else
+ this.render_lua(path, scope);
+ }
+ catch (ex) {
+ pop(this.scopes);
+ die(ex);
+ }
+
+ pop(this.scopes);
+ },
+
+ render: function(path, scope) {
+ let self = this;
+ this.env.http.write(render(() => self.render_any(path, scope)));
+ },
+
+ call: function(modname, method, ...args) {
+ let vm = this.init_lua();
+ let lcall = vm.get('_G', 'luci', 'ucodebridge', 'call');
+
+ return lcall.call(modname, method, ...args);
+ }
+};
+
+export default function(env) {
+ const self = proto({ env: env ??= {}, scopes: [ proto(env, global) ], global }, Class);
+ const uci = cursor();
+
+ // determine theme
+ let media = uci.get('luci', 'main', 'mediaurlbase');
+ let status = self.trycompile(`themes/${basename(media)}/header`);
+
+ if (status !== true) {
+ media = null;
+ self.env.media_error = status;
+
+ for (let k, v in uci.get_all('luci', 'themes')) {
+ if (substr(k, 0, 1) != '.') {
+ status = self.trycompile(`themes/${basename(v)}/header`);
+
+ if (status === true) {
+ media = v;
+ break;
+ }
+ }
+ }
+
+ if (!media)
+ env.dispatcher.error500(`Unable to render any theme header template, last error was:\n${status}`);
+ }
+
+ self.env.media = media;
+ self.env.theme = basename(media);
+ self.env.resource = uci.get('luci', 'main', 'resourcebase');
+ self.env.include = (...args) => self.render_any(...args);
+
+ return self;
+};
diff --git a/modules/luci-base/ucode/sys.uc b/modules/luci-base/ucode/sys.uc
new file mode 100644
index 0000000000..305499c797
--- /dev/null
+++ b/modules/luci-base/ucode/sys.uc
@@ -0,0 +1,157 @@
+// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+// Licensed to the public under the Apache License 2.0.
+
+import { basename, readlink, readfile, open, popen, stat, glob } from 'fs';
+
+export function process_list() {
+ const top = popen('/bin/busybox top -bn1');
+ let line, list = [];
+
+ for (let line = top.read('line'); length(line); line = top.read('line')) {
+ let m = match(trim(line), /^([0-9]+) +([0-9]+) +(.+) +([RSDZTWI][<NW ][<N ]) +([0-9]+m?) +([0-9]+%) +([0-9]+%) +(.+)$/);
+
+ if (m && m[8] != '/bin/busybox top -bn1') {
+ push(list, {
+ PID: m[1],
+ PPID: m[2],
+ USER: trim(m[3]),
+ STAT: m[4],
+ VSZ: m[5],
+ '%MEM': m[6],
+ '%CPU': m[7],
+ COMMAND: m[8]
+ });
+ }
+ }
+
+ top.close();
+
+ return list;
+};
+
+export function conntrack_list(callback) {
+ const etcpr = open('/etc/protocols');
+ const protos = {};
+
+ if (etcpr) {
+ for (let line = etcpr.read('line'); length(line); line = etcpr.read('line')) {
+ const m = match(line, /^([^# \t\n]+)\s+([0-9]+)\s+/);
+
+ if (m)
+ protos[m[2]] = m[1];
+ }
+
+ etcpr.close();
+ }
+
+ const nfct = open('/proc/net/nf_conntrack', 'r');
+ let connt;
+
+ if (nfct) {
+ for (let line = nfct.read('line'); length(line); line = nfct.read('line')) {
+ let m = match(line, /^(ipv[46]) +([0-9]+) +\S+ +([0-9]+)( +.+)\n$/);
+
+ if (!m)
+ continue;
+
+ let fam = m[1];
+ let l3 = m[2];
+ let l4 = m[3];
+ let tuples = m[4];
+ let timeout = null;
+
+ m = match(tuples, /^ +([0-9]+)( .+)$/);
+
+ if (m) {
+ timeout = m[1];
+ tuples = m[2];
+ }
+
+ if (index(tuples, 'TIME_WAIT') !== -1)
+ continue;
+
+ let e = {
+ bytes: 0,
+ packets: 0,
+ layer3: fam,
+ layer4: protos[l4] ?? 'unknown',
+ timeout: +timeout
+ };
+
+ for (let kv in match(tuples, / (\w+)=(\S+)/g)) {
+ switch (kv[1]) {
+ case 'bytes':
+ case 'packets':
+ e[kv[1]] += +kv[2];
+ break;
+
+ case 'src':
+ case 'dst':
+ e[kv[1]] ??= arrtoip(iptoarr(kv[2]));
+ break;
+
+ case 'sport':
+ case 'dport':
+ e[kv[1]] ??= +kv[2];
+ break;
+
+ default:
+ e[kv[1]] = kv[2];
+ }
+ }
+
+ if (callback)
+ callback(e);
+ else
+ push(connt ??= [], e);
+ }
+
+ nfct.close();
+ }
+
+ return callback ? true : (connt ?? []);
+};
+
+export function init_list() {
+ return map(filter(glob('/etc/init.d/*'), path => {
+ const s = stat(path);
+
+ return s?.type == 'file' && s?.perm?.user_exec;
+ }), basename);
+};
+
+export function init_index(name) {
+ const src = readfile(`/etc/init.d/${basename(name)}`, 2048);
+ const idx = [];
+
+ for (let m in match(src, /^[[:space:]]*(START|STOP)=('[0-9][0-9]'|"[0-9][0-9]"|[0-9][0-9])[[:space:]]*$/gs)) {
+ switch (m[1]) {
+ case 'START': idx[0] = +trim(m[2], '"\''); break;
+ case 'STOP': idx[1] = +trim(m[2], '"\''); break;
+ }
+ }
+
+ return length(idx) ? idx : null;
+};
+
+export function init_enabled(name) {
+ for (let path in glob(`/etc/rc.d/[SK][0-9][0-9]${basename(name)}`)) {
+ const ln = readlink(path);
+ const s1 = stat(index(ln, '/') == 0 ? ln : `/etc/rc.d/${ln}`);
+ const s2 = stat(`/etc/init.d/${basename(name)}`);
+
+ if (s1?.inode == s2?.inode && s1?.type == 'file' && s1?.perm?.user_exec)
+ return true;
+ }
+
+ return false;
+};
+
+export function init_action(name, action) {
+ const s = stat(`/etc/init.d/${basename(name)}`);
+
+ if (s?.type != 'file' || s?.user_exec == false)
+ return false;
+
+ return system(`env -i /etc/init.d/${basename(name)} ${action} >/dev/null`);
+};
diff --git a/modules/luci-base/ucode/template/csrftoken.ut b/modules/luci-base/ucode/template/csrftoken.ut
new file mode 100644
index 0000000000..4e96eebe90
--- /dev/null
+++ b/modules/luci-base/ucode/template/csrftoken.ut
@@ -0,0 +1,24 @@
+{#
+ Copyright 2015-2022 Jo-Philipp Wich <jo@mein.io>
+ Licensed to the public under the Apache License 2.0.
+-#}
+
+{% include('header') %}
+
+<h2 name="content">{{ _('Form token mismatch') }}</h2>
+<br />
+
+<p class="alert-message">{{ _('The submitted security token is invalid or already expired!') }}</p>
+
+<p>{{ _(`
+ In order to prevent unauthorized access to the system, your request has
+ been blocked. Click "Continue »" below to return to the previous page.
+`) }}</p>
+
+<hr />
+
+<p class="right">
+ <strong><a href="#" onclick="window.history.back();">Continue »</a></strong>
+</p>
+
+{% include('footer') %}
diff --git a/modules/luci-base/ucode/template/error404.ut b/modules/luci-base/ucode/template/error404.ut
new file mode 100644
index 0000000000..90c3d3784b
--- /dev/null
+++ b/modules/luci-base/ucode/template/error404.ut
@@ -0,0 +1,14 @@
+{#
+ Copyright 2008 Steven Barth <steven@midlink.org>
+ Copyright 2008-2022 Jo-Philipp Wich <jo@mein.io>
+ Licensed to the public under the Apache License 2.0.
+-#}
+
+{% include('header') %}
+
+<h2 name="content">404 {{ _('Not Found') }}</h2>
+<p>{{ _('Sorry, the object you requested was not found.') }}</p>
+<p>{{ message }}</p>
+<tt>{{ _('Unable to dispatch') }}: {{ dispatcher.build_url(...ctx.request_path) }}</tt>
+
+{% include('footer') %}
diff --git a/modules/luci-base/ucode/template/error500.ut b/modules/luci-base/ucode/template/error500.ut
new file mode 100644
index 0000000000..39a0eec678
--- /dev/null
+++ b/modules/luci-base/ucode/template/error500.ut
@@ -0,0 +1,67 @@
+{#
+ Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+ Licensed to the public under the Apache License 2.0.
+-#}
+
+<!--]]>--><!--'>--><!--">-->
+<style type="text/css">
+ body {
+ line-height: 1.5;
+ font-size: 14px;
+ font-family: sans-serif;
+ }
+
+ .error500 * {
+ margin: 0;
+ padding: 0;
+ color: inherit;
+ }
+
+ .error500 {
+ box-sizing: border-box;
+ position: fixed;
+ z-index: 999999;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ overflow: auto;
+ background: #ffe;
+ color: #f00 !important;
+ padding: 1em;
+ }
+
+ .error500 h1 {
+ margin-bottom: .5em;
+ }
+
+ .error500 .exception {
+ font-weight: normal;
+ white-space: normal;
+ margin: .25em;
+ padding: .5em;
+ border: 1px solid #f00;
+ background: rgba(204, 204, 204, .2);
+ }
+
+ .error500 .message {
+ font-weight: bold;
+ white-space: pre-line;
+ }
+
+ .error500 .context {
+ margin-top: 2em;
+ }
+</style>
+
+<div class="error500">
+ <h1>{{ title }}</h1>
+ <div class="message">{{ message }}</div>
+
+ {% if (exception): %}
+ <div class="exception">
+ <div class="message">{{ exception.message }}</div>
+ <pre class="context">{{ exception.stacktrace[0].context }}</pre>
+ </div>
+ {% endif %}
+</div>
diff --git a/modules/luci-base/ucode/template/footer.ut b/modules/luci-base/ucode/template/footer.ut
new file mode 100644
index 0000000000..d0978594f8
--- /dev/null
+++ b/modules/luci-base/ucode/template/footer.ut
@@ -0,0 +1,42 @@
+{#
+ Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+ Licensed to the public under the Apache License 2.0.
+-#}
+
+{% const rollback = dispatcher.rollback_pending() %}
+{% if (rollback || trigger_apply || trigger_revert): %}
+ <script type="text/javascript">
+ document.addEventListener("luci-loaded", function() {
+ {% if (trigger_apply): %}
+ L.ui.changes.apply(true);
+ {% elif (trigger_revert): %}
+ L.ui.changes.revert();
+ {% else %}
+ L.ui.changes.confirm(true, Date.now() + {{rollback.remaining * 1000}}, {{sprintf('%J', rollback.token)}});
+ {% endif %}
+ });
+ </script>
+{% endif %}
+
+{% if (media_error): %}
+ <script type="text/javascript">
+ L.require('ui').then(function(ui) {
+ ui.showIndicator('media_error', _('Theme fallback'), function(ev) {
+ ui.showModal(_('Error loading theme'), [
+ E('p', [
+ _('A fallback is used since the configured theme failed to load with the error below.')
+ ]),
+ E('hr'),
+ E('div', { 'style': 'white-space:pre-line' }, {{ sprintf('%J', trim(media_error)) }}),
+ E('div', { 'class': 'right' }, [
+ E('button', { 'class': 'btn cbi-button', 'click': ui.hideModal }, _('Dismiss'))
+ ])
+ ]);
+ });
+ });
+ </script>
+{% endif %}
+
+{% include(`themes/${theme}/footer`) %}
+
+<!-- Lua compatibility mode active: {{ lua_active ? 'yes' : 'no' }} -->
diff --git a/modules/luci-base/ucode/template/header.ut b/modules/luci-base/ucode/template/header.ut
new file mode 100644
index 0000000000..7dc3742a9d
--- /dev/null
+++ b/modules/luci-base/ucode/template/header.ut
@@ -0,0 +1,32 @@
+{#
+ Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+ Licensed to the public under the Apache License 2.0.
+-#}
+
+{%
+ include(`themes/${theme}/header`);
+-%}
+
+<script type="text/javascript" src="{{ resource }}/promis.min.js"></script>
+<script type="text/javascript" src="{{ resource }}/luci.js"></script>
+<script type="text/javascript">
+ L = new LuCI({{ replace(`${ {
+ media : media,
+ resource : resource,
+ scriptname : http.getenv("SCRIPT_NAME"),
+ pathinfo : http.getenv("PATH_INFO"),
+ documentroot : http.getenv("DOCUMENT_ROOT"),
+ requestpath : ctx.request_path,
+ dispatchpath : ctx.path,
+ pollinterval : +config.main.pollinterval || 5,
+ ubuspath : config.main.ubuspath || '/ubus/',
+ sessionid : ctx.authsession,
+ token : ctx.authtoken,
+ nodespec : dispatched,
+ apply_rollback : max(+config.apply.rollback || 90, 90),
+ apply_holdoff : max(+config.apply.holdoff || 4, 1),
+ apply_timeout : max(+config.apply.timeout || 5, 1),
+ apply_display : max(+config.apply.display || 1.5, 1),
+ rollback_token : rollback_token
+ } }`, '/', '\\/') }});
+</script>
diff --git a/modules/luci-base/ucode/template/sysauth.ut b/modules/luci-base/ucode/template/sysauth.ut
new file mode 100644
index 0000000000..3c580949bb
--- /dev/null
+++ b/modules/luci-base/ucode/template/sysauth.ut
@@ -0,0 +1,74 @@
+{#
+ Copyright 2008 Steven Barth <steven@midlink.org>
+ Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org>
+ Licensed to the public under the Apache License 2.0.
+-#}
+
+{% include('header') %}
+
+<form method="post">
+ {% if (fuser): %}
+ <div class="alert-message warning">
+ <p>{{ _('Invalid username and/or password! Please try again.') }}</p>
+ </div>
+ {% endif %}
+
+ <div class="cbi-map">
+ <h2 name="content">{{ _('Authorization Required') }}</h2>
+ <div class="cbi-map-descr">
+ {{ _('Please enter your username and password.') }}
+ </div>
+ <div class="cbi-section"><div class="cbi-section-node">
+ <div class="cbi-value">
+ <label class="cbi-value-title">{{ _('Username') }}</label>
+ <div class="cbi-value-field">
+ <input class="cbi-input-text" type="text" name="luci_username" value="{{ entityencode(duser, true) }}" />
+ </div>
+ </div>
+ <div class="cbi-value cbi-value-last">
+ <label class="cbi-value-title">{{ _('Password') }}</label>
+ <div class="cbi-value-field">
+ <input class="cbi-input-text" type="password" name="luci_password" />
+ </div>
+ </div>
+ </div></div>
+ </div>
+
+ <div class="cbi-page-actions">
+ <input type="submit" value="{{ _('Log in') }}" class="btn cbi-button cbi-button-apply" />
+ <input type="reset" value="{{ _('Reset') }}" class="btn cbi-button cbi-button-reset" />
+ </div>
+</form>
+
+{%
+ let https_ports = uci.get('uhttpd', 'main', 'listen_https') ?? [];
+
+ https_ports = uniq(filter(
+ map(
+ (type(https_ports) == 'string') ? split(https_port, /\s+/) : https_ports,
+ e => +match(e, /\d+$/)?.[0]
+ ),
+ p => (p >= 0 && p <= 65535)
+ ));
+%}
+
+<script type="text/javascript">//<![CDATA[
+ var input = document.getElementsByName('luci_password')[0];
+
+ if (input)
+ input.focus();
+
+ if (document.location.protocol != 'https:') {
+ {{ https_ports }}.forEach(function(port) {
+ var url = 'https://' + window.location.hostname + ':' + port + window.location.pathname;
+ var img = new Image();
+
+ img.onload = function() { window.location = url };
+ img.src = 'https://' + window.location.hostname + ':' + port + '{{ resource }}/icons/loading.gif?' + Math.random();
+
+ setTimeout(function() { img.src = '' }, 5000);
+ });
+ }
+//]]></script>
+
+{% include('footer') %}
diff --git a/modules/luci-base/ucode/template/view.ut b/modules/luci-base/ucode/template/view.ut
new file mode 100644
index 0000000000..11ac824290
--- /dev/null
+++ b/modules/luci-base/ucode/template/view.ut
@@ -0,0 +1,12 @@
+{% include('header') %}
+
+<div id="view">
+ <div class="spinning">{{ _('Loading view…') }}</div>
+ <script type="text/javascript">
+ L.require('ui').then(function(ui) {
+ ui.instantiateView('{{ view }}');
+ });
+ </script>
+</div>
+
+{% include('footer') %}
diff --git a/modules/luci-base/ucode/uhttpd.uc b/modules/luci-base/ucode/uhttpd.uc
new file mode 100644
index 0000000000..df1ecc7865
--- /dev/null
+++ b/modules/luci-base/ucode/uhttpd.uc
@@ -0,0 +1,12 @@
+{%
+
+import dispatch from 'luci.dispatcher';
+import request from 'luci.http';
+
+global.handle_request = function(env) {
+ let req = request(env, uhttpd.recv, uhttpd.send);
+
+ dispatch(req);
+
+ req.close();
+};
diff --git a/modules/luci-base/ucode/zoneinfo.uc b/modules/luci-base/ucode/zoneinfo.uc
new file mode 100644
index 0000000000..cbf2f7dce9
--- /dev/null
+++ b/modules/luci-base/ucode/zoneinfo.uc
@@ -0,0 +1,449 @@
+// Autogenerated by zoneinfo2ucode.pl
+
+export default {
+ 'Africa/Abidjan': 'GMT0',
+ 'Africa/Accra': 'GMT0',
+ 'Africa/Addis Ababa': 'EAT-3',
+ 'Africa/Algiers': 'CET-1',
+ 'Africa/Asmara': 'EAT-3',
+ 'Africa/Bamako': 'GMT0',
+ 'Africa/Bangui': 'WAT-1',
+ 'Africa/Banjul': 'GMT0',
+ 'Africa/Bissau': 'GMT0',
+ 'Africa/Blantyre': 'CAT-2',
+ 'Africa/Brazzaville': 'WAT-1',
+ 'Africa/Bujumbura': 'CAT-2',
+ 'Africa/Cairo': 'EET-2EEST,M4.5.5/0,M10.5.4/24',
+ 'Africa/Casablanca': '<+01>-1',
+ 'Africa/Ceuta': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Africa/Conakry': 'GMT0',
+ 'Africa/Dakar': 'GMT0',
+ 'Africa/Dar es Salaam': 'EAT-3',
+ 'Africa/Djibouti': 'EAT-3',
+ 'Africa/Douala': 'WAT-1',
+ 'Africa/El Aaiun': '<+01>-1',
+ 'Africa/Freetown': 'GMT0',
+ 'Africa/Gaborone': 'CAT-2',
+ 'Africa/Harare': 'CAT-2',
+ 'Africa/Johannesburg': 'SAST-2',
+ 'Africa/Juba': 'CAT-2',
+ 'Africa/Kampala': 'EAT-3',
+ 'Africa/Khartoum': 'CAT-2',
+ 'Africa/Kigali': 'CAT-2',
+ 'Africa/Kinshasa': 'WAT-1',
+ 'Africa/Lagos': 'WAT-1',
+ 'Africa/Libreville': 'WAT-1',
+ 'Africa/Lome': 'GMT0',
+ 'Africa/Luanda': 'WAT-1',
+ 'Africa/Lubumbashi': 'CAT-2',
+ 'Africa/Lusaka': 'CAT-2',
+ 'Africa/Malabo': 'WAT-1',
+ 'Africa/Maputo': 'CAT-2',
+ 'Africa/Maseru': 'SAST-2',
+ 'Africa/Mbabane': 'SAST-2',
+ 'Africa/Mogadishu': 'EAT-3',
+ 'Africa/Monrovia': 'GMT0',
+ 'Africa/Nairobi': 'EAT-3',
+ 'Africa/Ndjamena': 'WAT-1',
+ 'Africa/Niamey': 'WAT-1',
+ 'Africa/Nouakchott': 'GMT0',
+ 'Africa/Ouagadougou': 'GMT0',
+ 'Africa/Porto-Novo': 'WAT-1',
+ 'Africa/Sao Tome': 'GMT0',
+ 'Africa/Tripoli': 'EET-2',
+ 'Africa/Tunis': 'CET-1',
+ 'Africa/Windhoek': 'CAT-2',
+ 'America/Adak': 'HST10HDT,M3.2.0,M11.1.0',
+ 'America/Anchorage': 'AKST9AKDT,M3.2.0,M11.1.0',
+ 'America/Anguilla': 'AST4',
+ 'America/Antigua': 'AST4',
+ 'America/Araguaina': '<-03>3',
+ 'America/Argentina/Buenos Aires': '<-03>3',
+ 'America/Argentina/Catamarca': '<-03>3',
+ 'America/Argentina/Cordoba': '<-03>3',
+ 'America/Argentina/Jujuy': '<-03>3',
+ 'America/Argentina/La Rioja': '<-03>3',
+ 'America/Argentina/Mendoza': '<-03>3',
+ 'America/Argentina/Rio Gallegos': '<-03>3',
+ 'America/Argentina/Salta': '<-03>3',
+ 'America/Argentina/San Juan': '<-03>3',
+ 'America/Argentina/San Luis': '<-03>3',
+ 'America/Argentina/Tucuman': '<-03>3',
+ 'America/Argentina/Ushuaia': '<-03>3',
+ 'America/Aruba': 'AST4',
+ 'America/Asuncion': '<-04>4<-03>,M10.1.0/0,M3.4.0/0',
+ 'America/Atikokan': 'EST5',
+ 'America/Bahia': '<-03>3',
+ 'America/Bahia Banderas': 'CST6',
+ 'America/Barbados': 'AST4',
+ 'America/Belem': '<-03>3',
+ 'America/Belize': 'CST6',
+ 'America/Blanc-Sablon': 'AST4',
+ 'America/Boa Vista': '<-04>4',
+ 'America/Bogota': '<-05>5',
+ 'America/Boise': 'MST7MDT,M3.2.0,M11.1.0',
+ 'America/Cambridge Bay': 'MST7MDT,M3.2.0,M11.1.0',
+ 'America/Campo Grande': '<-04>4',
+ 'America/Cancun': 'EST5',
+ 'America/Caracas': '<-04>4',
+ 'America/Cayenne': '<-03>3',
+ 'America/Cayman': 'EST5',
+ 'America/Chicago': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Chihuahua': 'CST6',
+ 'America/Ciudad Juarez': 'MST7MDT,M3.2.0,M11.1.0',
+ 'America/Costa Rica': 'CST6',
+ 'America/Creston': 'MST7',
+ 'America/Cuiaba': '<-04>4',
+ 'America/Curacao': 'AST4',
+ 'America/Danmarkshavn': 'GMT0',
+ 'America/Dawson': 'MST7',
+ 'America/Dawson Creek': 'MST7',
+ 'America/Denver': 'MST7MDT,M3.2.0,M11.1.0',
+ 'America/Detroit': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Dominica': 'AST4',
+ 'America/Edmonton': 'MST7MDT,M3.2.0,M11.1.0',
+ 'America/Eirunepe': '<-05>5',
+ 'America/El Salvador': 'CST6',
+ 'America/Fort Nelson': 'MST7',
+ 'America/Fortaleza': '<-03>3',
+ 'America/Glace Bay': 'AST4ADT,M3.2.0,M11.1.0',
+ 'America/Goose Bay': 'AST4ADT,M3.2.0,M11.1.0',
+ 'America/Grand Turk': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Grenada': 'AST4',
+ 'America/Guadeloupe': 'AST4',
+ 'America/Guatemala': 'CST6',
+ 'America/Guayaquil': '<-05>5',
+ 'America/Guyana': '<-04>4',
+ 'America/Halifax': 'AST4ADT,M3.2.0,M11.1.0',
+ 'America/Havana': 'CST5CDT,M3.2.0/0,M11.1.0/1',
+ 'America/Hermosillo': 'MST7',
+ 'America/Indiana/Indianapolis': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Knox': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Marengo': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Petersburg': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Tell City': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Vevay': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Vincennes': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Indiana/Winamac': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Inuvik': 'MST7MDT,M3.2.0,M11.1.0',
+ 'America/Iqaluit': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Jamaica': 'EST5',
+ 'America/Juneau': 'AKST9AKDT,M3.2.0,M11.1.0',
+ 'America/Kentucky/Louisville': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Kentucky/Monticello': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Kralendijk': 'AST4',
+ 'America/La Paz': '<-04>4',
+ 'America/Lima': '<-05>5',
+ 'America/Los Angeles': 'PST8PDT,M3.2.0,M11.1.0',
+ 'America/Lower Princes': 'AST4',
+ 'America/Maceio': '<-03>3',
+ 'America/Managua': 'CST6',
+ 'America/Manaus': '<-04>4',
+ 'America/Marigot': 'AST4',
+ 'America/Martinique': 'AST4',
+ 'America/Matamoros': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Mazatlan': 'MST7',
+ 'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Merida': 'CST6',
+ 'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0',
+ 'America/Mexico City': 'CST6',
+ 'America/Miquelon': '<-03>3<-02>,M3.2.0,M11.1.0',
+ 'America/Moncton': 'AST4ADT,M3.2.0,M11.1.0',
+ 'America/Monterrey': 'CST6',
+ 'America/Montevideo': '<-03>3',
+ 'America/Montserrat': 'AST4',
+ 'America/Nassau': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/New York': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Nome': 'AKST9AKDT,M3.2.0,M11.1.0',
+ 'America/Noronha': '<-02>2',
+ 'America/North Dakota/Beulah': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/North Dakota/Center': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/North Dakota/New Salem': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Nuuk': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0',
+ 'America/Ojinaga': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Panama': 'EST5',
+ 'America/Paramaribo': '<-03>3',
+ 'America/Phoenix': 'MST7',
+ 'America/Port of Spain': 'AST4',
+ 'America/Port-au-Prince': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Porto Velho': '<-04>4',
+ 'America/Puerto Rico': 'AST4',
+ 'America/Punta Arenas': '<-03>3',
+ 'America/Rankin Inlet': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Recife': '<-03>3',
+ 'America/Regina': 'CST6',
+ 'America/Resolute': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Rio Branco': '<-05>5',
+ 'America/Santarem': '<-03>3',
+ 'America/Santiago': '<-04>4<-03>,M9.1.6/24,M4.1.6/24',
+ 'America/Santo Domingo': 'AST4',
+ 'America/Sao Paulo': '<-03>3',
+ 'America/Scoresbysund': '<-01>1<+00>,M3.5.0/0,M10.5.0/1',
+ 'America/Sitka': 'AKST9AKDT,M3.2.0,M11.1.0',
+ 'America/St Barthelemy': 'AST4',
+ 'America/St Johns': 'NST3:30NDT,M3.2.0,M11.1.0',
+ 'America/St Kitts': 'AST4',
+ 'America/St Lucia': 'AST4',
+ 'America/St Thomas': 'AST4',
+ 'America/St Vincent': 'AST4',
+ 'America/Swift Current': 'CST6',
+ 'America/Tegucigalpa': 'CST6',
+ 'America/Thule': 'AST4ADT,M3.2.0,M11.1.0',
+ 'America/Tijuana': 'PST8PDT,M3.2.0,M11.1.0',
+ 'America/Toronto': 'EST5EDT,M3.2.0,M11.1.0',
+ 'America/Tortola': 'AST4',
+ 'America/Vancouver': 'PST8PDT,M3.2.0,M11.1.0',
+ 'America/Whitehorse': 'MST7',
+ 'America/Winnipeg': 'CST6CDT,M3.2.0,M11.1.0',
+ 'America/Yakutat': 'AKST9AKDT,M3.2.0,M11.1.0',
+ 'Antarctica/Casey': '<+11>-11',
+ 'Antarctica/Davis': '<+07>-7',
+ 'Antarctica/DumontDUrville': '<+10>-10',
+ 'Antarctica/Macquarie': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
+ 'Antarctica/Mawson': '<+05>-5',
+ 'Antarctica/McMurdo': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
+ 'Antarctica/Palmer': '<-03>3',
+ 'Antarctica/Rothera': '<-03>3',
+ 'Antarctica/Syowa': '<+03>-3',
+ 'Antarctica/Troll': '<+00>0<+02>-2,M3.5.0/1,M10.5.0/3',
+ 'Antarctica/Vostok': '<+06>-6',
+ 'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Asia/Aden': '<+03>-3',
+ 'Asia/Almaty': '<+06>-6',
+ 'Asia/Amman': '<+03>-3',
+ 'Asia/Anadyr': '<+12>-12',
+ 'Asia/Aqtau': '<+05>-5',
+ 'Asia/Aqtobe': '<+05>-5',
+ 'Asia/Ashgabat': '<+05>-5',
+ 'Asia/Atyrau': '<+05>-5',
+ 'Asia/Baghdad': '<+03>-3',
+ 'Asia/Bahrain': '<+03>-3',
+ 'Asia/Baku': '<+04>-4',
+ 'Asia/Bangkok': '<+07>-7',
+ 'Asia/Barnaul': '<+07>-7',
+ 'Asia/Beirut': 'EET-2EEST,M3.5.0/0,M10.5.0/0',
+ 'Asia/Bishkek': '<+06>-6',
+ 'Asia/Brunei': '<+08>-8',
+ 'Asia/Chita': '<+09>-9',
+ 'Asia/Choibalsan': '<+08>-8',
+ 'Asia/Colombo': '<+0530>-5:30',
+ 'Asia/Damascus': '<+03>-3',
+ 'Asia/Dhaka': '<+06>-6',
+ 'Asia/Dili': '<+09>-9',
+ 'Asia/Dubai': '<+04>-4',
+ 'Asia/Dushanbe': '<+05>-5',
+ 'Asia/Famagusta': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Asia/Gaza': 'EET-2EEST,M3.4.4/50,M10.4.4/50',
+ 'Asia/Hebron': 'EET-2EEST,M3.4.4/50,M10.4.4/50',
+ 'Asia/Ho Chi Minh': '<+07>-7',
+ 'Asia/Hong Kong': 'HKT-8',
+ 'Asia/Hovd': '<+07>-7',
+ 'Asia/Irkutsk': '<+08>-8',
+ 'Asia/Jakarta': 'WIB-7',
+ 'Asia/Jayapura': 'WIT-9',
+ 'Asia/Jerusalem': 'IST-2IDT,M3.4.4/26,M10.5.0',
+ 'Asia/Kabul': '<+0430>-4:30',
+ 'Asia/Kamchatka': '<+12>-12',
+ 'Asia/Karachi': 'PKT-5',
+ 'Asia/Kathmandu': '<+0545>-5:45',
+ 'Asia/Khandyga': '<+09>-9',
+ 'Asia/Kolkata': 'IST-5:30',
+ 'Asia/Krasnoyarsk': '<+07>-7',
+ 'Asia/Kuala Lumpur': '<+08>-8',
+ 'Asia/Kuching': '<+08>-8',
+ 'Asia/Kuwait': '<+03>-3',
+ 'Asia/Macau': 'CST-8',
+ 'Asia/Magadan': '<+11>-11',
+ 'Asia/Makassar': 'WITA-8',
+ 'Asia/Manila': 'PST-8',
+ 'Asia/Muscat': '<+04>-4',
+ 'Asia/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Asia/Novokuznetsk': '<+07>-7',
+ 'Asia/Novosibirsk': '<+07>-7',
+ 'Asia/Omsk': '<+06>-6',
+ 'Asia/Oral': '<+05>-5',
+ 'Asia/Phnom Penh': '<+07>-7',
+ 'Asia/Pontianak': 'WIB-7',
+ 'Asia/Pyongyang': 'KST-9',
+ 'Asia/Qatar': '<+03>-3',
+ 'Asia/Qostanay': '<+06>-6',
+ 'Asia/Qyzylorda': '<+05>-5',
+ 'Asia/Riyadh': '<+03>-3',
+ 'Asia/Sakhalin': '<+11>-11',
+ 'Asia/Samarkand': '<+05>-5',
+ 'Asia/Seoul': 'KST-9',
+ 'Asia/Shanghai': 'CST-8',
+ 'Asia/Singapore': '<+08>-8',
+ 'Asia/Srednekolymsk': '<+11>-11',
+ 'Asia/Taipei': 'CST-8',
+ 'Asia/Tashkent': '<+05>-5',
+ 'Asia/Tbilisi': '<+04>-4',
+ 'Asia/Tehran': '<+0330>-3:30',
+ 'Asia/Thimphu': '<+06>-6',
+ 'Asia/Tokyo': 'JST-9',
+ 'Asia/Tomsk': '<+07>-7',
+ 'Asia/Ulaanbaatar': '<+08>-8',
+ 'Asia/Urumqi': '<+06>-6',
+ 'Asia/Ust-Nera': '<+10>-10',
+ 'Asia/Vientiane': '<+07>-7',
+ 'Asia/Vladivostok': '<+10>-10',
+ 'Asia/Yakutsk': '<+09>-9',
+ 'Asia/Yangon': '<+0630>-6:30',
+ 'Asia/Yekaterinburg': '<+05>-5',
+ 'Asia/Yerevan': '<+04>-4',
+ 'Atlantic/Azores': '<-01>1<+00>,M3.5.0/0,M10.5.0/1',
+ 'Atlantic/Bermuda': 'AST4ADT,M3.2.0,M11.1.0',
+ 'Atlantic/Canary': 'WET0WEST,M3.5.0/1,M10.5.0',
+ 'Atlantic/Cape Verde': '<-01>1',
+ 'Atlantic/Faroe': 'WET0WEST,M3.5.0/1,M10.5.0',
+ 'Atlantic/Madeira': 'WET0WEST,M3.5.0/1,M10.5.0',
+ 'Atlantic/Reykjavik': 'GMT0',
+ 'Atlantic/South Georgia': '<-02>2',
+ 'Atlantic/St Helena': 'GMT0',
+ 'Atlantic/Stanley': '<-03>3',
+ 'Australia/Adelaide': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
+ 'Australia/Brisbane': 'AEST-10',
+ 'Australia/Broken Hill': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
+ 'Australia/Darwin': 'ACST-9:30',
+ 'Australia/Eucla': '<+0845>-8:45',
+ 'Australia/Hobart': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
+ 'Australia/Lindeman': 'AEST-10',
+ 'Australia/Lord Howe': '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0',
+ 'Australia/Melbourne': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
+ 'Australia/Perth': 'AWST-8',
+ 'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
+ 'Etc/GMT': 'GMT0',
+ 'Etc/GMT+1': '<-01>1',
+ 'Etc/GMT+10': '<-10>10',
+ 'Etc/GMT+11': '<-11>11',
+ 'Etc/GMT+12': '<-12>12',
+ 'Etc/GMT+2': '<-02>2',
+ 'Etc/GMT+3': '<-03>3',
+ 'Etc/GMT+4': '<-04>4',
+ 'Etc/GMT+5': '<-05>5',
+ 'Etc/GMT+6': '<-06>6',
+ 'Etc/GMT+7': '<-07>7',
+ 'Etc/GMT+8': '<-08>8',
+ 'Etc/GMT+9': '<-09>9',
+ 'Etc/GMT-1': '<+01>-1',
+ 'Etc/GMT-10': '<+10>-10',
+ 'Etc/GMT-11': '<+11>-11',
+ 'Etc/GMT-12': '<+12>-12',
+ 'Etc/GMT-13': '<+13>-13',
+ 'Etc/GMT-14': '<+14>-14',
+ 'Etc/GMT-2': '<+02>-2',
+ 'Etc/GMT-3': '<+03>-3',
+ 'Etc/GMT-4': '<+04>-4',
+ 'Etc/GMT-5': '<+05>-5',
+ 'Etc/GMT-6': '<+06>-6',
+ 'Etc/GMT-7': '<+07>-7',
+ 'Etc/GMT-8': '<+08>-8',
+ 'Etc/GMT-9': '<+09>-9',
+ 'Europe/Amsterdam': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Andorra': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Astrakhan': '<+04>-4',
+ 'Europe/Athens': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Belgrade': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Bratislava': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Brussels': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Bucharest': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Budapest': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Busingen': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Chisinau': 'EET-2EEST,M3.5.0,M10.5.0/3',
+ 'Europe/Copenhagen': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Dublin': 'IST-1GMT0,M10.5.0,M3.5.0/1',
+ 'Europe/Gibraltar': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Guernsey': 'GMT0BST,M3.5.0/1,M10.5.0',
+ 'Europe/Helsinki': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Isle of Man': 'GMT0BST,M3.5.0/1,M10.5.0',
+ 'Europe/Istanbul': '<+03>-3',
+ 'Europe/Jersey': 'GMT0BST,M3.5.0/1,M10.5.0',
+ 'Europe/Kaliningrad': 'EET-2',
+ 'Europe/Kirov': 'MSK-3',
+ 'Europe/Kyiv': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Lisbon': 'WET0WEST,M3.5.0/1,M10.5.0',
+ 'Europe/Ljubljana': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0',
+ 'Europe/Luxembourg': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Madrid': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Malta': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Mariehamn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Minsk': '<+03>-3',
+ 'Europe/Monaco': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Moscow': 'MSK-3',
+ 'Europe/Oslo': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Paris': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Podgorica': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Prague': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Riga': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Rome': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Samara': '<+04>-4',
+ 'Europe/San Marino': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Sarajevo': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Saratov': '<+04>-4',
+ 'Europe/Simferopol': 'MSK-3',
+ 'Europe/Skopje': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Sofia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Stockholm': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Tallinn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Tirane': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Ulyanovsk': '<+04>-4',
+ 'Europe/Vaduz': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Vatican': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Vienna': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Vilnius': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
+ 'Europe/Volgograd': 'MSK-3',
+ 'Europe/Warsaw': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Zagreb': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Europe/Zurich': 'CET-1CEST,M3.5.0,M10.5.0/3',
+ 'Indian/Antananarivo': 'EAT-3',
+ 'Indian/Chagos': '<+06>-6',
+ 'Indian/Christmas': '<+07>-7',
+ 'Indian/Cocos': '<+0630>-6:30',
+ 'Indian/Comoro': 'EAT-3',
+ 'Indian/Kerguelen': '<+05>-5',
+ 'Indian/Mahe': '<+04>-4',
+ 'Indian/Maldives': '<+05>-5',
+ 'Indian/Mauritius': '<+04>-4',
+ 'Indian/Mayotte': 'EAT-3',
+ 'Indian/Reunion': '<+04>-4',
+ 'Pacific/Apia': '<+13>-13',
+ 'Pacific/Auckland': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
+ 'Pacific/Bougainville': '<+11>-11',
+ 'Pacific/Chatham': '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45',
+ 'Pacific/Chuuk': '<+10>-10',
+ 'Pacific/Easter': '<-06>6<-05>,M9.1.6/22,M4.1.6/22',
+ 'Pacific/Efate': '<+11>-11',
+ 'Pacific/Fakaofo': '<+13>-13',
+ 'Pacific/Fiji': '<+12>-12',
+ 'Pacific/Funafuti': '<+12>-12',
+ 'Pacific/Galapagos': '<-06>6',
+ 'Pacific/Gambier': '<-09>9',
+ 'Pacific/Guadalcanal': '<+11>-11',
+ 'Pacific/Guam': 'ChST-10',
+ 'Pacific/Honolulu': 'HST10',
+ 'Pacific/Kanton': '<+13>-13',
+ 'Pacific/Kiritimati': '<+14>-14',
+ 'Pacific/Kosrae': '<+11>-11',
+ 'Pacific/Kwajalein': '<+12>-12',
+ 'Pacific/Majuro': '<+12>-12',
+ 'Pacific/Marquesas': '<-0930>9:30',
+ 'Pacific/Midway': 'SST11',
+ 'Pacific/Nauru': '<+12>-12',
+ 'Pacific/Niue': '<-11>11',
+ 'Pacific/Norfolk': '<+11>-11<+12>,M10.1.0,M4.1.0/3',
+ 'Pacific/Noumea': '<+11>-11',
+ 'Pacific/Pago Pago': 'SST11',
+ 'Pacific/Palau': '<+09>-9',
+ 'Pacific/Pitcairn': '<-08>8',
+ 'Pacific/Pohnpei': '<+11>-11',
+ 'Pacific/Port Moresby': '<+10>-10',
+ 'Pacific/Rarotonga': '<-10>10',
+ 'Pacific/Saipan': 'ChST-10',
+ 'Pacific/Tahiti': '<-10>10',
+ 'Pacific/Tarawa': '<+12>-12',
+ 'Pacific/Tongatapu': '<+13>-13',
+ 'Pacific/Wake': '<+12>-12',
+ 'Pacific/Wallis': '<+12>-12',
+};