aredn/files/app/root.ut

658 lines
22 KiB
Plaintext

{%
/*
* Part of AREDN® -- Used for creating Amateur Radio Emergency Data Networks
* Copyright (C) 2024 Tim Wilkinson
* See Contributors file for additional contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Additional Terms:
*
* Additional use restrictions exist on the AREDN® trademark and logo.
* See AREDNLicense.txt for more info.
*
* Attributions to the AREDN® Project must be retained in the source code.
* If importing this code into a new or existing project attribution
* to the AREDN® project must be added to the source code.
*
* You must not misrepresent the origin of the material contained within.
*
* Modified versions must be modified to attribute to the original source
* and be marked in reasonable ways as differentiate it from the original
* version
*/
%}
{%
import * as config from "./config.uc";
import * as fs from "fs";
import * as math from "math";
import * as uci from "uci";
import * as ubus from "ubus";
import * as log from "log";
import * as zlib from "zlib";
import * as lucihttp from "lucihttp";
import * as configuration from "aredn.configuration";
import * as hardware from "aredn.hardware";
import * as lqm from "aredn.lqm";
import * as network from "aredn.network";
import * as olsr from "aredn.olsr";
import * as units from "aredn.units";
import * as radios from "aredn.radios";
import * as messages from "aredn.messages";
import * as mesh from "aredn.mesh";
import * as constants from "./constants.uc";
const pageCache = {};
const resourceVersions = {};
log.openlog("uhttpd.aredn", log.LOG_PID, log.LOG_USER);
const light = fs.readfile(`${config.application}/resource/css/themes/light.css`);
const dark = fs.readfile(`${config.application}/resource/css/themes/dark.css`);
fs.writefile(`${config.application}/resource/css/themes/default.css`, `${light}@media (prefers-color-scheme: dark) {\n${dark}\n}`);
if (!fs.access(`${config.application}/resource/css/theme.css`)) {
fs.symlink("themes/default.css", `${config.application}/resource/css/theme.css`);
}
if (config.preload) {
function cp(path) {
const dir = fs.opendir(`${config.application}${path}`);
for (;;) {
const entry = dir.read();
if (!entry) {
break;
}
if (match(entry, /\.ut$/)) {
const tpath = `${config.application}${path}${entry}`;
pageCache[tpath] = loadfile(tpath, { raw_mode: false });
}
}
}
cp("/main/");
cp("/partial/");
radios.getCommonConfiguration();
}
if (config.resourcehash) {
function prepareResource(id, resource)
{
const path = `${config.application}/resource/${resource}`;
const pathgz = `${config.application}/resource/${resource}.gz`;
if (fs.access(path)) {
fs.unlink(pathgz);
system(`/bin/gzip -k ${path}`);
}
const md = fs.popen(`/usr/bin/md5sum ${pathgz}`);
resourceVersions[id] = match(md.read("all"), /^([0-9a-f]+)/)[1];
md.close();
const m = match(path, /^(.+)\.([a-z]+)$/);
fs.symlink(pathgz, `${m[1]}.${resourceVersions[id]}.${m[2]}.gz`);
}
prepareResource("usercss", "css/user.css");
prepareResource("admincss", "css/admin.css");
prepareResource("mobilecss", "css/mobile.css");
prepareResource("themecss", "css/theme.css");
prepareResource("htmx", "js/htmx.min.js");
prepareResource("meshpage", "js/meshpage.js");
let cthemeversion = null;
const ctheme = fs.readlink(`${config.application}/resource/css/theme.css`);
const themes = fs.lsdir(`${config.application}/resource/css/themes`);
for (let i = 0; i < length(themes); i++) {
const theme = themes[i];
if (match(theme, /^.*\.css$/)) {
prepareResource("themecss", `css/themes/${theme}`);
if (ctheme === `themes/${theme}`) {
cthemeversion = resourceVersions.themecss;
}
fs.unlink(`${config.application}/resource/css/theme.${resourceVersions.themecss}.css.gz`);
fs.symlink(`themes/${replace(theme, /\.css$/, "")}.${resourceVersions.themecss}.css.gz`, `${config.application}/resource/css/theme.${resourceVersions.themecss}.css.gz`);
}
}
resourceVersions.themecss = cthemeversion;
fs.unlink(`${config.application}/resource/css/theme.version`);
fs.symlink(cthemeversion, `${config.application}/resource/css/theme.version`);
}
else {
let dir = fs.lsdir(`${config.application}/resource/css`);
for (let i = 0; i < length(dir); i++) {
if (match(dir[i], /\.gz$/)) {
fs.unlink(`${config.application}/resource/css/${dir[i]}`);
}
}
dir = fs.lsdir(`${config.application}/resource/css/themes`);
for (let i = 0; i < length(dir); i++) {
if (match(dir[i], /\.gz$/)) {
fs.unlink(`${config.application}/resource/css/themes/${dir[i]}`);
}
}
dir = fs.lsdir(`${config.application}/resource/js`);
for (let i = 0; i < length(dir); i++) {
if (match(dir[i], /\.gz$/) && dir[i] !== "htmx.min.js.gz") {
fs.unlink(`${config.application}/resource/js/${dir[i]}`);
}
}
fs.unlink(`${config.application}/resource/css/theme.version`);
}
global._R = function(path, arg)
{
const tpath = `${config.application}/partial/${path}.ut`;
const fn = pageCache[tpath] || loadfile(tpath, { raw_mode: false });
let old = inner;
let r = "";
try {
inner = arg;
r = render(fn);
}
catch (_) {
}
inner = old;
return r;
};
global._H = function(str)
{
return includeHelp ? `<div class="help">${str}</div>` : "";
};
const uciMethods =
{
init: function()
{
if (!cursor)
{
cursor = uci.cursor();
}
},
add: function(a, b)
{
this.init();
cursor.load(a);
return cursor.add(a, b);
},
get: function(a, b, c)
{
this.init();
return cursor.get(a, b, c);
},
set: function(a, b, c, d)
{
this.init();
if (d === undefined) {
cursor.set(a, b, c);
}
else {
if (type(d) === "array") {
for (let i = 0; i < length(d); i++) {
d[i] = replace(replace(`${d[i]}`, /['<>]/g, ""), /[\r\n]/g, " ");
}
}
else {
d = replace(replace(`${d}`, /['<>]/g, ""), /[\r\n]/g, " ");
}
cursor.set(a, b, c, d);
}
},
foreach: function(a, b, fn)
{
this.init();
cursor.foreach(a, b, fn);
},
commit: function(a)
{
if (cursor) {
cursor.commit(a);
}
},
"delete": function(a, b)
{
this.init();
cursor.delete(a, b);
},
error: function()
{
if (cursor) {
return cursor.error();
}
}
};
const uciMeshMethods =
{
init: function()
{
if (!cursorm)
{
cursorm = uci.cursor("/etc/config.mesh");
}
},
load: function(a)
{
this.init();
cursorm.load(a);
},
add: function(a, b)
{
this.init();
cursorm.load(a);
return cursorm.add(a, b);
},
get: function(a, b, c)
{
this.init();
return cursorm.get(a, b, c);
},
set: function(a, b, c, d)
{
this.init();
if (d === undefined) {
cursorm.set(a, b, c);
}
else {
if (type(d) === "array") {
for (let i = 0; i < length(d); i++) {
d[i] = replace(replace(`${d[i]}`, /['<>]/g, ""), /[\r\n]/g, " ");
}
}
else {
d = replace(replace(`${d}`, /['<>]/g, ""), /[\r\n]/g, " ");
}
cursorm.set(a, b, c, d);
}
},
foreach: function(a, b, fn)
{
this.init();
cursorm.foreach(a, b, fn);
},
commit: function(a)
{
if (cursorm) {
cursorm.commit(a);
}
},
"delete": function(a, b)
{
this.init();
cursorm.delete(a, b);
},
error: function()
{
if (cursorm) {
return cursorm.error();
}
}
};
const auth = {
isAdmin: false,
key: null,
age: 315360000, // 10 years
DAYS: [ "", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" ],
MONTHS: [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ],
initKey: function()
{
if (!this.key) {
const f = fs.open("/etc/shadow");
if (f) {
for (let l = f.read("line"); length(l); l = f.read("line")) {
if (index(l, "root:") === 0) {
this.key = trim(l);
break;
}
}
f.close();
}
}
},
runAuthentication: function(env)
{
const cookieheader = env.headers?.cookie;
if (cookieheader) {
const ca = split(cookieheader, ";");
for (let i = 0; i < length(ca); i++) {
const cookie = trim(ca[i]);
if (index(cookie, "authV1=") === 0) {
this.initKey();
if (this.key == b64dec(substr(cookie, 7))) {
this.isAdmin = true;
}
else {
this.isAdmin = false;
}
break;
}
}
}
if (config.forceauth) {
this.isAdmin = true;
}
},
authenticate: function(password)
{
if (!this.isAdmin) {
this.initKey();
const s = split(this.key, /[:$]/); // s[3] = salt, s[4] = hashed
const f = fs.popen(`exec /usr/bin/mkpasswd -S '${s[3]}' '${password}'`);
if (f) {
const pwd = rtrim(f.read("all"));
f.close();
if (index(this.key, `root:${pwd}:`) === 0) {
const time = clock();
const gm = gmtime(time[0] + this.age);
const tm = `${this.DAYS[gm.wday]}, ${gm.mday} ${this.MONTHS[gm.mon]} ${gm.year} 00:00:00 GMT`;
response.headers["Set-Cookie"] = `authV1=${b64enc(this.key)}; Path=/; Domain=${replace(request.headers.host, /:\d+$/, "")}; Expires=${tm}; SameSite=Strict`;
this.isAdmin = true;
}
}
}
return this.isAdmin;
},
deauthenticate: function()
{
if (this.isAdmin) {
response.headers["Set-Cookie"] = `authV1=; Path=/; Domain=${replace(request.headers.host, /:\d+$/, "")}; Max-Age=0;`;
this.isAdmin = false;
}
}
};
const ubusMethods =
{
call: function(path, method)
{
if (!connection) {
connection = ubus.connect();
}
return connection.call(path, method);
}
};
const rePath = /^\/([-a-z]*)(.*)$/;
global.handle_request = function(env)
{
const path = match(env.PATH_INFO, rePath);
const page = path[1] || "status";
const secured = index(path[2], "/e/") === 0;
if (path[2] == "" || secured) {
let tpath;
if (!configuration.isConfigured()) {
tpath = `${config.application}/main/firstuse-ram.ut`;
const f = fs.open("/proc/mounts");
if (f) {
for (let l = f.read("line"); length(l); l = f.read("line")) {
if (index(l, "overlay") !== -1 || index(l, "ext4") !== -1) {
tpath = `${config.application}/main/firstuse.ut`;
const mac2 = uci.cursor("/etc/local/uci").get("hsmmmesh", "settings", "mac2");
if (!match(mac2, /^\d+\.\d+\.\d+$/)) {
tpath = `${config.application}/main/firstuse-start.ut`;
}
break;
}
}
f.close();
}
}
else if (secured) {
tpath = `${config.application}/main${env.PATH_INFO}.ut`;
}
else if ((env.HTTP_HOST === "localnode" || env.HTTP_HOST === "localnode.local.mesh") && env.REQUEST_URI == "/a/status" && !env.headers["hx-boosted"]) {
uhttpd.send(`Status: 302 Found\r\nLocation: http://${configuration.getName()}.local.mesh\r\n\r\n`);
return;
}
else {
tpath = `${config.application}/main/${page}.ut`;
if (!pageCache[tpath] && !fs.access(tpath)) {
tpath = `${config.application}/main/app.ut`;
}
}
if (pageCache[tpath] || fs.access(tpath)) {
auth.runAuthentication(env);
if (config.authenable && !auth.isAdmin) {
if (secured) {
uhttpd.send("Status: 401 Unauthorized\r\n\r\n");
return;
}
if (env.REQUEST_METHOD !== "GET" && configuration.isConfigured()) {
uhttpd.send("Status: 401 Unauthorized\r\n\r\n");
return;
}
}
const args = {};
if (env.CONTENT_TYPE === "application/x-www-form-urlencoded") {
let b = "";
for (;;) {
const v = uhttpd.recv(10240);
if (!length(v)) {
break;
}
b += v;
}
const v = split(b, "&");
for (let i = 0; i < length(v); i++) {
const kv = split(v[i], "=");
const k = uhttpd.urldecode(kv[0]);
if (!(k in args)) {
args[k] = uhttpd.urldecode(kv[1]);
}
}
}
if (index(env.CONTENT_TYPE, "multipart/form-data") === 0) {
let key;
let val;
let header;
let file;
let parser;
parser = lucihttp.multipart_parser(env.CONTENT_TYPE, (what, buffer, length) => {
switch (what) {
case parser.PART_INIT:
key = null;
val = null;
break;
case parser.HEADER_NAME:
header = lc(buffer);
break;
case parser.HEADER_VALUE:
if (header === "content-disposition") {
const filename = lucihttp.header_attribute(buffer, "filename");
key = lucihttp.header_attribute(buffer, "name");
file = {
name: `/tmp/${key}`,
filename: filename
};
val = filename;
}
break;
case parser.PART_BEGIN:
if (file) {
fs.writefile("/proc/sys/vm/drop_caches", "3");
file.fd = fs.open(file.name, "w");
return false
}
break;
case parser.PART_DATA:
if (file) {
file.fd.write(buffer);
}
else {
val = buffer;
}
break;
case parser.PART_END:
if (file) {
file.fd.close();
file.fd = null;
args[key] = file.name;
}
else if (key) {
args[key] = val;
}
key = null;
val = null;
file = null;
break;
case parser.ERROR:
log.syslog(log.LOG_ERR, `multipart error: ${buffer}`);
break;
}
return true;
});
for (;;) {
const v = uhttpd.recv(10240);
if (!length(v)) {
parser.parse(null);
break;
}
parser.parse(v);
}
}
const response = { statusCode: 200, headers: { "Content-Type": "text/html", "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*" } };
const fn = pageCache[tpath] || loadfile(tpath, { raw_mode: false });
const isMobile = !!(config.forcemobile || match(env.HTTP_USER_AGENT, /iphone/i) || match(env.HTTP_USER_AGENT, /android.*mobile/i));
let res = "";
try {
res = render(call, fn, null, {
config: config,
constants: constants,
versions: resourceVersions,
request: { env: env, headers: env.headers, args: args, page: page, mobile: isMobile },
response: response,
uci: uciMethods,
uciMesh: uciMeshMethods,
ubus: ubusMethods,
auth: auth,
includeHelp: (env.headers || {})["include-help"] === "1",
includeAdvanced: (env.headers || {})["include-advanced"] === "1",
fs: fs,
configuration: configuration,
hardware: hardware,
lqm: lqm,
network: network,
olsr: olsr,
units: units,
radios: radios,
math: math,
log: log,
messages: messages,
mesh: mesh
});
}
catch (e) {
log.syslog(log.LOG_ERR, `${e.message}\n${e.stacktrace[0].context}`);
res = `<div><b>ERROR: ${e.message}</b><div><pre>${e.stacktrace[0].context}</pre></div>`;
}
if (!response.override) {
let gzip = false;
if (env.HTTP_ACCEPT_ENCODING && index(env.HTTP_ACCEPT_ENCODING, "gzip") !== -1 && config.compress) {
gzip = true;
res = zlib.deflate(res, true);
}
response.headers["Content-Length"] = `${length(res)}`;
uhttpd.send(
`Status: ${response.statusCode} OK\r\n`,
(gzip ? `Content-Encoding: gzip\r\n` : ``),
join("", map(keys(response.headers), k => k + ": " + response.headers[k] + "\r\n")),
"\r\n",
res
);
}
if (response.reboot) {
system(`(sleep 2; ${response.reboot})&`);
}
return;
}
uhttpd.send("Status: 404 Not Found\r\n\r\n");
return;
}
const rpath = `${config.application}/resource/${env.PATH_INFO}`;
const gzrpath = `${rpath}.gz`;
const localnode = env.HTTP_HOST === "localnode" || env.HTTP_HOST === "localnode.local.mesh";
const cachecontrol = localnode || !config.resourcehash ? "no-store" : "max-age=604800";
if (localnode && config.resourcehash && fs.access(gzrpath)) {
const themecss = fs.readlink(`${config.application}/resource/css/theme.version`);
if (env.PATH_INFO !== `/css/theme.${themecss}.css` && index(env.PATH_INFO, "/css/theme.") === 0 && uciMethods.get("aredn", "@theme[0]", "portable") === "1") {
uhttpd.send(`Status: 302 Found\r\nContent-Type: text/css\r\nCache-Control: no-store\r\nLocation: /a/css/theme.${themecss}.css\r\n\r\n`);
return;
}
}
if (fs.access(gzrpath)) {
uhttpd.send("Status: 200 OK\r\nContent-Encoding: gzip\r\n");
if (substr(rpath, -3) === ".js") {
uhttpd.send("Content-Type: application/javascript\r\n");
}
else if (substr(rpath, -4) === ".css") {
uhttpd.send("Content-Type: text/css\r\n");
}
else if (substr(rpath, -4) === ".svg") {
uhttpd.send("Content-Type: image/svg+xml\r\n");
}
const res = fs.readfile(gzrpath);
uhttpd.send(`Cache-Control: ${cachecontrol}\r\nContent-Length: ${length(res)}\n`);
uhttpd.send("\r\n", res);
return;
}
if (fs.access(rpath)) {
uhttpd.send("Status: 200 OK\r\n");
if (substr(rpath, -3) === ".js") {
uhttpd.send("Content-Type: application/javascript\r\n");
}
else if (substr(rpath, -4) === ".png") {
uhttpd.send("Content-Type: image/png\r\n");
}
else if (substr(rpath, -4) === ".jpg") {
uhttpd.send("Content-Type: image/jpeg\r\n");
}
else if (substr(rpath, -4) === ".svg") {
uhttpd.send("Content-Type: image/svg+xml\r\n");
}
else if (substr(rpath, -4) === ".css") {
uhttpd.send("Content-Type: text/css\r\n");
}
const res = fs.readfile(rpath);
uhttpd.send(`Cache-Control: ${cachecontrol}\r\nContent-Length: ${length(res)}\n`);
uhttpd.send("\r\n", res);
return;
}
uhttpd.send(`Status: 404 Not Found\r\nCache-Control: ${cachecontrol}\r\n\r\n`);
};
%}