proxmox-widget-toolkit/src/Utils.js
Dominik Csapak 9e69d726e0 fix external linking to products by setting cookie SameSite attribute to lax
We introduced the 'strict' setting when browsers warned about our
cookies not having any SameSite setting [0]. While this works in
general, it had an unforeseen side effect:

When opening a link to the web UI of Proxmox projects, any existing
cookie does not get sent on the initial page load due to coming from
another origin. This then leads to the username and CSRF prevention
token not being set in the index response.
The UI code interprets this as the user being logged out (e.g. because
the ticket is not valid) and clears the cookie, displaying the login
window, even if the cookie's ticket value was still valid.

The MDN reference[1] says that setting it to 'lax' is similar to
'strict', but sends the cookie when navigating *to* our origin even
from other sites, which is what we want when linking from elsewhere.
(This would have also been the default if we wouldn't have set any
attribute).

[0]: https://lore.proxmox.com/pve-devel/20230315162630.289768-1-m.carrara@proxmox.com/
[1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#SameSite_attribute

Fixes: aec7e8d ("toolkit/utils: set SameSite attr of auth cookie to 'strict'")
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
2024-10-15 15:25:14 +02:00

1547 lines
43 KiB
JavaScript

Ext.ns('Proxmox');
Ext.ns('Proxmox.Setup');
if (!Ext.isDefined(Proxmox.Setup.auth_cookie_name)) {
throw "Proxmox library not initialized";
}
// avoid errors when running without development tools
if (!Ext.isDefined(Ext.global.console)) {
let console = {
dir: function() {
// do nothing
},
log: function() {
// do nothing
},
warn: function() {
// do nothing
},
};
Ext.global.console = console;
}
Ext.Ajax.defaultHeaders = {
'Accept': 'application/json',
};
Ext.Ajax.on('beforerequest', function(conn, options) {
if (Proxmox.CSRFPreventionToken) {
if (!options.headers) {
options.headers = {};
}
options.headers.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
}
let storedAuth = Proxmox.Utils.getStoredAuth();
if (storedAuth.token) {
options.headers.Authorization = storedAuth.token;
}
});
Ext.define('Proxmox.Utils', { // a singleton
utilities: {
yesText: gettext('Yes'),
noText: gettext('No'),
enabledText: gettext('Enabled'),
disabledText: gettext('Disabled'),
noneText: gettext('none'),
NoneText: gettext('None'),
errorText: gettext('Error'),
warningsText: gettext('Warnings'),
unknownText: gettext('Unknown'),
defaultText: gettext('Default'),
daysText: gettext('days'),
dayText: gettext('day'),
runningText: gettext('running'),
stoppedText: gettext('stopped'),
neverText: gettext('never'),
totalText: gettext('Total'),
usedText: gettext('Used'),
directoryText: gettext('Directory'),
stateText: gettext('State'),
groupText: gettext('Group'),
language_map: { //language map is sorted alphabetically by iso 639-1
ar: `العربية - ${gettext("Arabic")}`,
ca: `Català - ${gettext("Catalan")}`,
da: `Dansk - ${gettext("Danish")}`,
de: `Deutsch - ${gettext("German")}`,
en: `English - ${gettext("English")}`,
es: `Español - ${gettext("Spanish")}`,
eu: `Euskera (Basque) - ${gettext("Euskera (Basque)")}`,
fa: `فارسی - ${gettext("Persian (Farsi)")}`,
fr: `Français - ${gettext("French")}`,
hr: `Hrvatski - ${gettext("Croatian")}`,
he: `עברית - ${gettext("Hebrew")}`,
it: `Italiano - ${gettext("Italian")}`,
ja: `日本語 - ${gettext("Japanese")}`,
ka: `ქართული - ${gettext("Georgian")}`,
ko: `한국어 - ${gettext("Korean")}`,
nb: `Bokmål - ${gettext("Norwegian (Bokmal)")}`,
nl: `Nederlands - ${gettext("Dutch")}`,
nn: `Nynorsk - ${gettext("Norwegian (Nynorsk)")}`,
pl: `Polski - ${gettext("Polish")}`,
pt_BR: `Português Brasileiro - ${gettext("Portuguese (Brazil)")}`,
ru: `Русский - ${gettext("Russian")}`,
sl: `Slovenščina - ${gettext("Slovenian")}`,
sv: `Svenska - ${gettext("Swedish")}`,
tr: `Türkçe - ${gettext("Turkish")}`,
ukr: `Українська - ${gettext("Ukrainian")}`,
zh_CN: `中文(简体)- ${gettext("Chinese (Simplified)")}`,
zh_TW: `中文(繁體)- ${gettext("Chinese (Traditional)")}`,
},
render_language: function(value) {
if (!value || value === '__default__') {
return Proxmox.Utils.defaultText + ' (English)';
}
if (value === 'kr') {
value = 'ko'; // fix-up wrongly used Korean code. FIXME: remove with trixie releases
}
let text = Proxmox.Utils.language_map[value];
if (text) {
return text + ' (' + value + ')';
}
return value;
},
renderEnabledIcon: enabled => `<i class="fa fa-${enabled ? 'check' : 'minus'}"></i>`,
language_array: function() {
let data = [['__default__', Proxmox.Utils.render_language('')]];
Ext.Object.each(Proxmox.Utils.language_map, function(key, value) {
data.push([key, Proxmox.Utils.render_language(value)]);
});
return data;
},
theme_map: {
crisp: 'Light theme',
"proxmox-dark": 'Proxmox Dark',
},
render_theme: function(value) {
if (!value || value === '__default__') {
return Proxmox.Utils.defaultText + ' (auto)';
}
let text = Proxmox.Utils.theme_map[value];
if (text) {
return text;
}
return value;
},
theme_array: function() {
let data = [['__default__', Proxmox.Utils.render_theme('')]];
Ext.Object.each(Proxmox.Utils.theme_map, function(key, value) {
data.push([key, Proxmox.Utils.render_theme(value)]);
});
return data;
},
bond_mode_gettext_map: {
'802.3ad': 'LACP (802.3ad)',
'lacp-balance-slb': 'LACP (balance-slb)',
'lacp-balance-tcp': 'LACP (balance-tcp)',
},
render_bond_mode: value => Proxmox.Utils.bond_mode_gettext_map[value] || value || '',
bond_mode_array: function(modes) {
return modes.map(mode => [mode, Proxmox.Utils.render_bond_mode(mode)]);
},
getNoSubKeyHtml: function(url) {
let html_url = Ext.String.format('<a target="_blank" href="{0}">www.proxmox.com</a>', url || 'https://www.proxmox.com');
return Ext.String.format(
gettext('You do not have a valid subscription for this server. Please visit {0} to get a list of available options.'),
html_url,
);
},
format_boolean_with_default: function(value) {
if (Ext.isDefined(value) && value !== '__default__') {
return value ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
}
return Proxmox.Utils.defaultText;
},
format_boolean: function(value) {
return value ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
},
format_neg_boolean: function(value) {
return !value ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
},
format_enabled_toggle: function(value) {
return value ? Proxmox.Utils.enabledText : Proxmox.Utils.disabledText;
},
format_expire: function(date) {
if (!date) {
return Proxmox.Utils.neverText;
}
return Ext.Date.format(date, "Y-m-d");
},
// somewhat like a human would tell durations, omit zero values and do not
// give seconds precision if we talk days already
format_duration_human: function(ut) {
let seconds = 0, minutes = 0, hours = 0, days = 0, years = 0;
if (ut <= 0.1) {
return '<0.1s';
}
let remaining = ut;
seconds = Number((remaining % 60).toFixed(1));
remaining = Math.trunc(remaining / 60);
if (remaining > 0) {
minutes = remaining % 60;
remaining = Math.trunc(remaining / 60);
if (remaining > 0) {
hours = remaining % 24;
remaining = Math.trunc(remaining / 24);
if (remaining > 0) {
days = remaining % 365;
remaining = Math.trunc(remaining / 365); // yea, just lets ignore leap years...
if (remaining > 0) {
years = remaining;
}
}
}
}
let res = [];
let add = (t, unit) => {
if (t > 0) res.push(t + unit);
return t > 0;
};
let addMinutes = !add(years, 'y');
let addSeconds = !add(days, 'd');
add(hours, 'h');
if (addMinutes) {
add(minutes, 'm');
if (addSeconds) {
add(seconds, 's');
}
}
return res.join(' ');
},
format_duration_long: function(ut) {
let days = Math.floor(ut / 86400);
ut -= days*86400;
let hours = Math.floor(ut / 3600);
ut -= hours*3600;
let mins = Math.floor(ut / 60);
ut -= mins*60;
let hours_str = '00' + hours.toString();
hours_str = hours_str.substr(hours_str.length - 2);
let mins_str = "00" + mins.toString();
mins_str = mins_str.substr(mins_str.length - 2);
let ut_str = "00" + ut.toString();
ut_str = ut_str.substr(ut_str.length - 2);
if (days) {
let ds = days > 1 ? Proxmox.Utils.daysText : Proxmox.Utils.dayText;
return days.toString() + ' ' + ds + ' ' +
hours_str + ':' + mins_str + ':' + ut_str;
} else {
return hours_str + ':' + mins_str + ':' + ut_str;
}
},
format_subscription_level: function(level) {
if (level === 'c') {
return 'Community';
} else if (level === 'b') {
return 'Basic';
} else if (level === 's') {
return 'Standard';
} else if (level === 'p') {
return 'Premium';
} else {
return Proxmox.Utils.noneText;
}
},
compute_min_label_width: function(text, width) {
if (width === undefined) { width = 100; }
let tm = new Ext.util.TextMetrics();
let min = tm.getWidth(text + ':');
return min < width ? width : min;
},
// returns username + realm
parse_userid: function(userid) {
if (!Ext.isString(userid)) {
return [undefined, undefined];
}
let match = userid.match(/^(.+)@([^@]+)$/);
if (match !== null) {
return [match[1], match[2]];
}
return [undefined, undefined];
},
render_username: function(userid) {
let username = Proxmox.Utils.parse_userid(userid)[0] || "";
return Ext.htmlEncode(username);
},
render_realm: function(userid) {
let username = Proxmox.Utils.parse_userid(userid)[1] || "";
return Ext.htmlEncode(username);
},
getStoredAuth: function() {
let storedAuth = JSON.parse(window.localStorage.getItem('ProxmoxUser'));
return storedAuth || {};
},
setAuthData: function(data) {
Proxmox.UserName = data.username;
Proxmox.LoggedOut = data.LoggedOut;
// creates a session cookie (expire = null)
// that way the cookie gets deleted after the browser window is closed
if (data.ticket) {
Proxmox.CSRFPreventionToken = data.CSRFPreventionToken;
Ext.util.Cookies.set(Proxmox.Setup.auth_cookie_name, data.ticket, null, '/', null, true, "lax");
}
if (data.token) {
window.localStorage.setItem('ProxmoxUser', JSON.stringify(data));
}
},
authOK: function() {
if (Proxmox.LoggedOut) {
return undefined;
}
let storedAuth = Proxmox.Utils.getStoredAuth();
let cookie = Ext.util.Cookies.get(Proxmox.Setup.auth_cookie_name);
if ((Proxmox.UserName !== '' && cookie && !cookie.startsWith("PVE:tfa!")) || storedAuth.token) {
return cookie || storedAuth.token;
} else {
return false;
}
},
authClear: function() {
if (Proxmox.LoggedOut) {
return;
}
// ExtJS clear is basically the same, but browser may complain if any cookie isn't "secure"
Ext.util.Cookies.set(Proxmox.Setup.auth_cookie_name, "", new Date(0), null, null, true, "lax");
window.localStorage.removeItem("ProxmoxUser");
},
// The End-User gets redirected back here after login on the OpenID auth. portal, and in the
// redirection URL the state and auth.code are passed as URL GET params, this helper parses those
getOpenIDRedirectionAuthorization: function() {
const auth = Ext.Object.fromQueryString(window.location.search);
if (auth.state !== undefined && auth.code !== undefined) {
return auth;
}
return undefined;
},
// comp.setLoading() is buggy in ExtJS 4.0.7, so we
// use el.mask() instead
setErrorMask: function(comp, msg) {
let el = comp.el;
if (!el) {
return;
}
if (!msg) {
el.unmask();
} else if (msg === true) {
el.mask(gettext("Loading..."));
} else {
el.mask(msg);
}
},
getResponseErrorMessage: (err) => {
if (!err.statusText) {
return gettext('Connection error');
}
let msg = [`${err.statusText} (${err.status})`];
if (err.response && err.response.responseText) {
let txt = err.response.responseText;
try {
let res = JSON.parse(txt);
if (res.errors && typeof res.errors === 'object') {
for (let [key, value] of Object.entries(res.errors)) {
msg.push(Ext.String.htmlEncode(`${key}: ${value}`));
}
}
} catch (e) {
// fallback to string
msg.push(Ext.String.htmlEncode(txt));
}
}
return msg.join('<br>');
},
monStoreErrors: function(component, store, clearMaskBeforeLoad, errorCallback) {
if (clearMaskBeforeLoad) {
component.mon(store, 'beforeload', function(s, operation, eOpts) {
Proxmox.Utils.setErrorMask(component, false);
});
} else {
component.mon(store, 'beforeload', function(s, operation, eOpts) {
if (!component.loadCount) {
component.loadCount = 0; // make sure it is nucomponent.ic
Proxmox.Utils.setErrorMask(component, true);
}
});
}
// only works with 'proxmox' proxy
component.mon(store.proxy, 'afterload', function(proxy, request, success) {
component.loadCount++;
if (success) {
Proxmox.Utils.setErrorMask(component, false);
return;
}
let error = request._operation.getError();
let msg = Proxmox.Utils.getResponseErrorMessage(error);
if (!errorCallback || !errorCallback(error, msg)) {
Proxmox.Utils.setErrorMask(component, msg);
}
});
},
extractRequestError: function(result, verbose) {
let msg = gettext('Successful');
if (!result.success) {
msg = gettext("Unknown error");
if (result.message) {
msg = Ext.htmlEncode(result.message);
if (result.status) {
msg += ` (${result.status})`;
}
}
if (verbose && Ext.isObject(result.errors)) {
msg += "<br>";
Ext.Object.each(result.errors, (prop, desc) => {
msg += `<br><b>${Ext.htmlEncode(prop)}</b>: ${Ext.htmlEncode(desc)}`;
});
}
}
return msg;
},
// Ext.Ajax.request
API2Request: function(reqOpts) {
let newopts = Ext.apply({
waitMsg: gettext('Please wait...'),
}, reqOpts);
// default to enable if user isn't handling the failure already explicitly
let autoErrorAlert = reqOpts.autoErrorAlert ??
(typeof reqOpts.failure !== 'function' && typeof reqOpts.callback !== 'function');
if (!newopts.url.match(/^\/api2/)) {
newopts.url = '/api2/extjs' + newopts.url;
}
delete newopts.callback;
let unmask = (target) => {
if (target.waitMsgTargetCount === undefined || --target.waitMsgTargetCount <= 0) {
target.setLoading(false);
delete target.waitMsgTargetCount;
}
};
let createWrapper = function(successFn, callbackFn, failureFn) {
Ext.apply(newopts, {
success: function(response, options) {
if (options.waitMsgTarget) {
if (Proxmox.Utils.toolkit === 'touch') {
options.waitMsgTarget.setMasked(false);
} else {
unmask(options.waitMsgTarget);
}
}
let result = Ext.decode(response.responseText);
response.result = result;
if (!result.success) {
response.htmlStatus = Proxmox.Utils.extractRequestError(result, true);
Ext.callback(callbackFn, options.scope, [options, false, response]);
Ext.callback(failureFn, options.scope, [response, options]);
if (autoErrorAlert) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
}
return;
}
Ext.callback(callbackFn, options.scope, [options, true, response]);
Ext.callback(successFn, options.scope, [response, options]);
},
failure: function(response, options) {
if (options.waitMsgTarget) {
if (Proxmox.Utils.toolkit === 'touch') {
options.waitMsgTarget.setMasked(false);
} else {
unmask(options.waitMsgTarget);
}
}
response.result = {};
try {
response.result = Ext.decode(response.responseText);
} catch (e) {
// ignore
}
let msg = gettext('Connection error') + ' - server offline?';
if (response.aborted) {
msg = gettext('Connection error') + ' - aborted.';
} else if (response.timedout) {
msg = gettext('Connection error') + ' - Timeout.';
} else if (response.status && response.statusText) {
msg = gettext('Connection error') + ' ' + response.status + ': ' + response.statusText;
}
response.htmlStatus = msg;
Ext.callback(callbackFn, options.scope, [options, false, response]);
Ext.callback(failureFn, options.scope, [response, options]);
},
});
};
createWrapper(reqOpts.success, reqOpts.callback, reqOpts.failure);
let target = newopts.waitMsgTarget;
if (target) {
if (Proxmox.Utils.toolkit === 'touch') {
target.setMasked({ xtype: 'loadmask', message: newopts.waitMsg });
} else if (target.rendered) {
target.waitMsgTargetCount = (target.waitMsgTargetCount ?? 0) + 1;
target.setLoading(newopts.waitMsg);
} else {
target.waitMsgTargetCount = (target.waitMsgTargetCount ?? 0) + 1;
target.on('afterlayout', function() {
if ((target.waitMsgTargetCount ?? 0) > 0) {
target.setLoading(newopts.waitMsg);
}
}, target, { single: true });
}
}
Ext.Ajax.request(newopts);
},
// can be useful for catching displaying errors from the API, e.g.:
// Proxmox.Async.api2({
// ...
// }).catch(Proxmox.Utils.alertResponseFailure);
alertResponseFailure: res => Ext.Msg.alert(gettext('Error'), res.htmlStatus || res.result.message),
checked_command: function(orig_cmd) {
Proxmox.Utils.API2Request(
{
url: '/nodes/localhost/subscription',
method: 'GET',
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, opts) {
let res = response.result;
if (res === null || res === undefined || !res || res
.data.status.toLowerCase() !== 'active') {
Ext.Msg.show({
title: gettext('No valid subscription'),
icon: Ext.Msg.WARNING,
message: Proxmox.Utils.getNoSubKeyHtml(res.data.url),
buttons: Ext.Msg.OK,
callback: function(btn) {
if (btn !== 'ok') {
return;
}
orig_cmd();
},
});
} else {
orig_cmd();
}
},
},
);
},
assemble_field_data: function(values, data) {
if (!Ext.isObject(data)) {
return;
}
Ext.Object.each(data, function(name, val) {
if (Object.prototype.hasOwnProperty.call(values, name)) {
let bucket = values[name];
if (!Ext.isArray(bucket)) {
bucket = values[name] = [bucket];
}
if (Ext.isArray(val)) {
values[name] = bucket.concat(val);
} else {
bucket.push(val);
}
} else {
values[name] = val;
}
});
},
updateColumnWidth: function(container, thresholdWidth) {
let mode = Ext.state.Manager.get('summarycolumns') || 'auto';
let factor;
if (mode !== 'auto') {
factor = parseInt(mode, 10);
if (Number.isNaN(factor)) {
factor = 1;
}
} else {
thresholdWidth = (thresholdWidth || 1400) + 1;
factor = Math.ceil(container.getSize().width / thresholdWidth);
}
if (container.oldFactor === factor) {
return;
}
let items = container.query('>'); // direct children
factor = Math.min(factor, items.length);
container.oldFactor = factor;
items.forEach((item) => {
item.columnWidth = 1 / factor;
});
// we have to update the layout twice, since the first layout change
// can trigger the scrollbar which reduces the amount of space left
container.updateLayout();
container.updateLayout();
},
// NOTE: depreacated, use updateColumnWidth
updateColumns: container => Proxmox.Utils.updateColumnWidth(container),
dialog_title: function(subject, create, isAdd) {
if (create) {
if (isAdd) {
return gettext('Add') + ': ' + subject;
} else {
return gettext('Create') + ': ' + subject;
}
} else {
return gettext('Edit') + ': ' + subject;
}
},
network_iface_types: {
eth: gettext("Network Device"),
bridge: 'Linux Bridge',
bond: 'Linux Bond',
vlan: 'Linux VLAN',
OVSBridge: 'OVS Bridge',
OVSBond: 'OVS Bond',
OVSPort: 'OVS Port',
OVSIntPort: 'OVS IntPort',
},
render_network_iface_type: function(value) {
return Proxmox.Utils.network_iface_types[value] ||
Proxmox.Utils.unknownText;
},
// Only add product-agnostic fields here!
notificationFieldName: {
'type': gettext('Notification type'),
'hostname': gettext('Hostname'),
},
formatNotificationFieldName: (value) =>
Proxmox.Utils.notificationFieldName[value] || value,
// to add or change existing for product specific ones
overrideNotificationFieldName: function(extra) {
for (const [key, value] of Object.entries(extra)) {
Proxmox.Utils.notificationFieldName[key] = value;
}
},
// Only add product-agnostic fields here!
notificationFieldValue: {
'system-mail': gettext('Forwarded mails to the local root user'),
},
formatNotificationFieldValue: (value) =>
Proxmox.Utils.notificationFieldValue[value] || value,
// to add or change existing for product specific ones
overrideNotificationFieldValue: function(extra) {
for (const [key, value] of Object.entries(extra)) {
Proxmox.Utils.notificationFieldValue[key] = value;
}
},
// NOTE: only add general, product agnostic, ones here! Else use override helper in product repos
task_desc_table: {
aptupdate: ['', gettext('Update package database')],
diskinit: ['Disk', gettext('Initialize Disk with GPT')],
spiceshell: ['', gettext('Shell') + ' (Spice)'],
srvreload: ['SRV', gettext('Reload')],
srvrestart: ['SRV', gettext('Restart')],
srvstart: ['SRV', gettext('Start')],
srvstop: ['SRV', gettext('Stop')],
termproxy: ['', gettext('Console') + ' (xterm.js)'],
vncshell: ['', gettext('Shell')],
},
// to add or change existing for product specific ones
override_task_descriptions: function(extra) {
for (const [key, value] of Object.entries(extra)) {
Proxmox.Utils.task_desc_table[key] = value;
}
},
format_task_description: function(type, id) {
let farray = Proxmox.Utils.task_desc_table[type];
let text;
if (!farray) {
text = type;
if (id) {
type += ' ' + id;
}
return text;
} else if (Ext.isFunction(farray)) {
return farray(type, id);
}
let prefix = farray[0];
text = farray[1];
if (prefix && id !== undefined) {
return prefix + ' ' + id + ' - ' + text;
}
return text;
},
format_size: function(size, useSI) {
let unitsSI = [gettext('B'), gettext('KB'), gettext('MB'), gettext('GB'),
gettext('TB'), gettext('PB'), gettext('EB'), gettext('ZB'), gettext('YB')];
let unitsIEC = [gettext('B'), gettext('KiB'), gettext('MiB'), gettext('GiB'),
gettext('TiB'), gettext('PiB'), gettext('EiB'), gettext('ZiB'), gettext('YiB')];
let order = 0;
let commaDigits = 2;
const baseValue = useSI ? 1000 : 1024;
while (size >= baseValue && order < unitsSI.length) {
size = size / baseValue;
order++;
}
let unit = useSI ? unitsSI[order] : unitsIEC[order];
if (order === 0) {
commaDigits = 0;
}
return `${size.toFixed(commaDigits)} ${unit}`;
},
SizeUnits: {
'B': 1,
'KiB': 1024,
'MiB': 1024*1024,
'GiB': 1024*1024*1024,
'TiB': 1024*1024*1024*1024,
'PiB': 1024*1024*1024*1024*1024,
'KB': 1000,
'MB': 1000*1000,
'GB': 1000*1000*1000,
'TB': 1000*1000*1000*1000,
'PB': 1000*1000*1000*1000*1000,
},
parse_size_unit: function(val) {
//let m = val.match(/([.\d])+\s?([KMGTP]?)(i?)B?\s*$/i);
let m = val.match(/(\d+(?:\.\d+)?)\s?([KMGTP]?)(i?)B?\s*$/i);
let size = parseFloat(m[1]);
let scale = m[2].toUpperCase();
let binary = m[3].toLowerCase();
let unit = `${scale}${binary}B`;
let factor = Proxmox.Utils.SizeUnits[unit];
return { size, factor, unit, binary }; // for convenience return all we got
},
size_unit_to_bytes: function(val) {
let { size, factor } = Proxmox.Utils.parse_size_unit(val);
return size * factor;
},
autoscale_size_unit: function(val) {
let { size, factor, binary } = Proxmox.Utils.parse_size_unit(val);
return Proxmox.Utils.format_size(size * factor, binary !== "i");
},
size_unit_ratios: function(a, b) {
a = typeof a !== "undefined" ? a : 0;
b = typeof b !== "undefined" ? b : Infinity;
let aBytes = typeof a === "number" ? a : Proxmox.Utils.size_unit_to_bytes(a);
let bBytes = typeof b === "number" ? b : Proxmox.Utils.size_unit_to_bytes(b);
return aBytes / (bBytes || Infinity); // avoid division by zero
},
render_upid: function(value, metaData, record) {
let task = record.data;
let type = task.type || task.worker_type;
let id = task.id || task.worker_id;
return Proxmox.Utils.format_task_description(type, id);
},
render_uptime: function(value) {
let uptime = value;
if (uptime === undefined) {
return '';
}
if (uptime <= 0) {
return '-';
}
return Proxmox.Utils.format_duration_long(uptime);
},
systemd_unescape: function(string_value) {
const charcode_0 = '0'.charCodeAt(0);
const charcode_9 = '9'.charCodeAt(0);
const charcode_A = 'A'.charCodeAt(0);
const charcode_F = 'F'.charCodeAt(0);
const charcode_a = 'a'.charCodeAt(0);
const charcode_f = 'f'.charCodeAt(0);
const charcode_x = 'x'.charCodeAt(0);
const charcode_minus = '-'.charCodeAt(0);
const charcode_slash = '/'.charCodeAt(0);
const charcode_backslash = '\\'.charCodeAt(0);
let parse_hex_digit = function(d) {
if (d >= charcode_0 && d <= charcode_9) {
return d - charcode_0;
}
if (d >= charcode_A && d <= charcode_F) {
return d - charcode_A + 10;
}
if (d >= charcode_a && d <= charcode_f) {
return d - charcode_a + 10;
}
throw "got invalid hex digit";
};
let value = new TextEncoder().encode(string_value);
let result = new Uint8Array(value.length);
let i = 0;
let result_len = 0;
while (i < value.length) {
let c0 = value[i];
if (c0 === charcode_minus) {
result.set([charcode_slash], result_len);
result_len += 1;
i += 1;
continue;
}
if ((i + 4) < value.length) {
let c1 = value[i+1];
if (c0 === charcode_backslash && c1 === charcode_x) {
let h1 = parse_hex_digit(value[i+2]);
let h0 = parse_hex_digit(value[i+3]);
let ord = h1*16+h0;
result.set([ord], result_len);
result_len += 1;
i += 4;
continue;
}
}
result.set([c0], result_len);
result_len += 1;
i += 1;
}
return new TextDecoder().decode(result.slice(0, result.len));
},
parse_task_upid: function(upid) {
let task = {};
let res = upid.match(/^UPID:([^\s:]+):([0-9A-Fa-f]{8}):([0-9A-Fa-f]{8,9}):(([0-9A-Fa-f]{8,16}):)?([0-9A-Fa-f]{8}):([^:\s]+):([^:\s]*):([^:\s]+):$/);
if (!res) {
throw "unable to parse upid '" + upid + "'";
}
task.node = res[1];
task.pid = parseInt(res[2], 16);
task.pstart = parseInt(res[3], 16);
if (res[5] !== undefined) {
task.task_id = parseInt(res[5], 16);
}
task.starttime = parseInt(res[6], 16);
task.type = res[7];
task.id = Proxmox.Utils.systemd_unescape(res[8]);
task.user = res[9];
task.desc = Proxmox.Utils.format_task_description(task.type, task.id);
return task;
},
parse_task_status: function(status) {
if (status === 'OK') {
return 'ok';
}
if (status === 'unknown') {
return 'unknown';
}
let match = status.match(/^WARNINGS: (.*)$/);
if (match) {
return 'warning';
}
return 'error';
},
format_task_status: function(status) {
let parsed = Proxmox.Utils.parse_task_status(status);
switch (parsed) {
case 'unknown': return Proxmox.Utils.unknownText;
case 'error': return Proxmox.Utils.errorText + ': ' + status;
case 'warning': return status.replace('WARNINGS', Proxmox.Utils.warningsText);
case 'ok': // fall-through
default: return status;
}
},
render_duration: function(value) {
if (value === undefined) {
return '-';
}
return Proxmox.Utils.format_duration_human(value);
},
render_timestamp: function(value, metaData, record, rowIndex, colIndex, store) {
let servertime = new Date(value * 1000);
return Ext.Date.format(servertime, 'Y-m-d H:i:s');
},
render_zfs_health: function(value) {
if (typeof value === 'undefined') {
return "";
}
var iconCls = 'question-circle';
switch (value) {
case 'AVAIL':
case 'ONLINE':
iconCls = 'check-circle good';
break;
case 'REMOVED':
case 'DEGRADED':
iconCls = 'exclamation-circle warning';
break;
case 'UNAVAIL':
case 'FAULTED':
case 'OFFLINE':
iconCls = 'times-circle critical';
break;
default: //unknown
}
return '<i class="fa fa-' + iconCls + '"></i> ' + value;
},
get_help_info: function(section) {
let helpMap;
if (typeof proxmoxOnlineHelpInfo !== 'undefined') {
helpMap = proxmoxOnlineHelpInfo; // eslint-disable-line no-undef
} else if (typeof pveOnlineHelpInfo !== 'undefined') {
// be backward compatible with older pve-doc-generators
helpMap = pveOnlineHelpInfo; // eslint-disable-line no-undef
} else {
throw "no global OnlineHelpInfo map declared";
}
if (helpMap[section]) {
return helpMap[section];
}
// try to normalize - and _ separators, to support asciidoc and sphinx
// references at the same time.
let section_minus_normalized = section.replace(/_/g, '-');
if (helpMap[section_minus_normalized]) {
return helpMap[section_minus_normalized];
}
let section_underscore_normalized = section.replace(/-/g, '_');
return helpMap[section_underscore_normalized];
},
get_help_link: function(section) {
let info = Proxmox.Utils.get_help_info(section);
if (!info) {
return undefined;
}
return window.location.origin + info.link;
},
openXtermJsViewer: function(vmtype, vmid, nodename, vmname, cmd) {
let url = Ext.Object.toQueryString({
console: vmtype, // kvm, lxc, upgrade or shell
xtermjs: 1,
vmid: vmid,
vmname: vmname,
node: nodename,
cmd: cmd,
});
let nw = window.open("?" + url, '_blank', 'toolbar=no,location=no,status=no,menubar=no,resizable=yes,width=800,height=420');
if (nw) {
nw.focus();
}
},
render_optional_url: function(value) {
if (value && value.match(/^https?:\/\//) !== null) {
return '<a target="_blank" href="' + value + '">' + value + '</a>';
}
return value;
},
render_san: function(value) {
var names = [];
if (Ext.isArray(value)) {
value.forEach(function(val) {
if (!Ext.isNumber(val)) {
names.push(val);
}
});
return names.join('<br>');
}
return value;
},
render_usage: val => (val * 100).toFixed(2) + '%',
render_cpu_usage: function(val, max) {
return Ext.String.format(
`${gettext('{0}% of {1}')} ${gettext('CPU(s)')}`,
(val*100).toFixed(2),
max,
);
},
render_size_usage: function(val, max, useSI) {
if (max === 0) {
return gettext('N/A');
}
let fmt = v => Proxmox.Utils.format_size(v, useSI);
let ratio = (val * 100 / max).toFixed(2);
return ratio + '% (' + Ext.String.format(gettext('{0} of {1}'), fmt(val), fmt(max)) + ')';
},
render_cpu: function(value, metaData, record, rowIndex, colIndex, store) {
if (!(record.data.uptime && Ext.isNumeric(value))) {
return '';
}
let maxcpu = record.data.maxcpu || 1;
if (!Ext.isNumeric(maxcpu) || maxcpu < 1) {
return '';
}
let cpuText = maxcpu > 1 ? 'CPUs' : 'CPU';
let ratio = (value * 100).toFixed(1);
return `${ratio}% of ${maxcpu.toString()} ${cpuText}`;
},
render_size: function(value, metaData, record, rowIndex, colIndex, store) {
if (!Ext.isNumeric(value)) {
return '';
}
return Proxmox.Utils.format_size(value);
},
render_cpu_model: function(cpu) {
let socketText = cpu.sockets > 1 ? gettext('Sockets') : gettext('Socket');
return `${cpu.cpus} x ${cpu.model} (${cpu.sockets.toString()} ${socketText})`;
},
/* this is different for nodes */
render_node_cpu_usage: function(value, record) {
return Proxmox.Utils.render_cpu_usage(value, record.cpus);
},
render_node_size_usage: function(record) {
return Proxmox.Utils.render_size_usage(record.used, record.total);
},
loadTextFromFile: function(file, callback, maxBytes) {
let maxSize = maxBytes || 8192;
if (file.size > maxSize) {
Ext.Msg.alert(gettext('Error'), gettext("Invalid file size: ") + file.size);
return;
}
let reader = new FileReader();
reader.onload = evt => callback(evt.target.result);
reader.readAsText(file);
},
parsePropertyString: function(value, defaultKey) {
var res = {},
error;
if (typeof value !== 'string' || value === '') {
return res;
}
Ext.Array.each(value.split(','), function(p) {
var kv = p.split('=', 2);
if (Ext.isDefined(kv[1])) {
res[kv[0]] = kv[1];
} else if (Ext.isDefined(defaultKey)) {
if (Ext.isDefined(res[defaultKey])) {
error = 'defaultKey may be only defined once in propertyString';
return false; // break
}
res[defaultKey] = kv[0];
} else {
error = 'invalid propertyString, not a key=value pair and no defaultKey defined';
return false; // break
}
return true;
});
if (error !== undefined) {
console.error(error);
return undefined;
}
return res;
},
printPropertyString: function(data, defaultKey) {
var stringparts = [],
gotDefaultKeyVal = false,
defaultKeyVal;
Ext.Object.each(data, function(key, value) {
if (defaultKey !== undefined && key === defaultKey) {
gotDefaultKeyVal = true;
defaultKeyVal = value;
} else if (Ext.isArray(value)) {
stringparts.push(key + '=' + value.join(';'));
} else if (value !== '') {
stringparts.push(key + '=' + value);
}
});
stringparts = stringparts.sort();
if (gotDefaultKeyVal) {
stringparts.unshift(defaultKeyVal);
}
return stringparts.join(',');
},
acmedomain_count: 5,
parseACMEPluginData: function(data) {
let res = {};
let extradata = [];
data.split('\n').forEach((line) => {
// capture everything after the first = as value
let [key, value] = line.split('=');
if (value !== undefined) {
res[key] = value;
} else {
extradata.push(line);
}
});
return [res, extradata];
},
delete_if_default: function(values, fieldname, default_val, create) {
if (values[fieldname] === '' || values[fieldname] === default_val) {
if (!create) {
if (values.delete) {
if (Ext.isArray(values.delete)) {
values.delete.push(fieldname);
} else {
values.delete += ',' + fieldname;
}
} else {
values.delete = fieldname;
}
}
delete values[fieldname];
}
},
printACME: function(value) {
if (Ext.isArray(value.domains)) {
value.domains = value.domains.join(';');
}
return Proxmox.Utils.printPropertyString(value);
},
parseACME: function(value) {
if (!value) {
return {};
}
var res = {};
var error;
Ext.Array.each(value.split(','), function(p) {
var kv = p.split('=', 2);
if (Ext.isDefined(kv[1])) {
res[kv[0]] = kv[1];
} else {
error = 'Failed to parse key-value pair: '+p;
return false;
}
return true;
});
if (error !== undefined) {
console.error(error);
return undefined;
}
if (res.domains !== undefined) {
res.domains = res.domains.split(/;/);
}
return res;
},
add_domain_to_acme: function(acme, domain) {
if (acme.domains === undefined) {
acme.domains = [domain];
} else {
acme.domains.push(domain);
acme.domains = acme.domains.filter((value, index, self) => self.indexOf(value) === index);
}
return acme;
},
remove_domain_from_acme: function(acme, domain) {
if (acme.domains !== undefined) {
acme.domains = acme.domains.filter(
(value, index, self) => self.indexOf(value) === index && value !== domain,
);
}
return acme;
},
get_health_icon: function(state, circle) {
if (circle === undefined) {
circle = false;
}
if (state === undefined) {
state = 'uknown';
}
var icon = 'faded fa-question';
switch (state) {
case 'good':
icon = 'good fa-check';
break;
case 'upgrade':
icon = 'warning fa-upload';
break;
case 'old':
icon = 'warning fa-refresh';
break;
case 'warning':
icon = 'warning fa-exclamation';
break;
case 'critical':
icon = 'critical fa-times';
break;
default: break;
}
if (circle) {
icon += '-circle';
}
return icon;
},
formatNodeRepoStatus: function(status, product) {
let fmt = (txt, cls) => `<i class="fa fa-fw fa-lg fa-${cls}"></i>${txt}`;
let getUpdates = Ext.String.format(gettext('{0} updates'), product);
let noRepo = Ext.String.format(gettext('No {0} repository enabled!'), product);
if (status === 'ok') {
return fmt(getUpdates, 'check-circle good') + ' ' +
fmt(gettext('Production-ready Enterprise repository enabled'), 'check-circle good');
} else if (status === 'no-sub') {
return fmt(gettext('Production-ready Enterprise repository enabled'), 'check-circle good') + ' ' +
fmt(gettext('Enterprise repository needs valid subscription'), 'exclamation-circle warning');
} else if (status === 'non-production') {
return fmt(getUpdates, 'check-circle good') + ' ' +
fmt(gettext('Non production-ready repository enabled!'), 'exclamation-circle warning');
} else if (status === 'no-repo') {
return fmt(noRepo, 'exclamation-circle critical');
}
return Proxmox.Utils.unknownText;
},
render_u2f_error: function(error) {
var ErrorNames = {
'1': gettext('Other Error'),
'2': gettext('Bad Request'),
'3': gettext('Configuration Unsupported'),
'4': gettext('Device Ineligible'),
'5': gettext('Timeout'),
};
return "U2F Error: " + ErrorNames[error] || Proxmox.Utils.unknownText;
},
// Convert an ArrayBuffer to a base64url encoded string.
// A `null` value will be preserved for convenience.
bytes_to_base64url: function(bytes) {
if (bytes === null) {
return null;
}
return btoa(Array
.from(new Uint8Array(bytes))
.map(val => String.fromCharCode(val))
.join(''),
)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/[=]/g, '');
},
// Convert an a base64url string to an ArrayBuffer.
// A `null` value will be preserved for convenience.
base64url_to_bytes: function(b64u) {
if (b64u === null) {
return null;
}
return new Uint8Array(
atob(b64u
.replace(/-/g, '+')
.replace(/_/g, '/'),
)
.split('')
.map(val => val.charCodeAt(0)),
);
},
stringToRGB: function(string) {
let hash = 0;
if (!string) {
return hash;
}
string += 'prox'; // give short strings more variance
for (let i = 0; i < string.length; i++) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; // to int
}
let alpha = 0.7; // make the color a bit brighter
let bg = 255; // assume white background
return [
(hash & 255) * alpha + bg * (1 - alpha),
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
];
},
rgbToCss: function(rgb) {
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
},
rgbToHex: function(rgb) {
let r = Math.round(rgb[0]).toString(16);
let g = Math.round(rgb[1]).toString(16);
let b = Math.round(rgb[2]).toString(16);
return `${r}${g}${b}`;
},
hexToRGB: function(hex) {
if (!hex) {
return undefined;
}
if (hex.length === 7) {
hex = hex.slice(1);
}
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
return [r, g, b];
},
// optimized & simplified SAPC function
// https://github.com/Myndex/SAPC-APCA
getTextContrastClass: function(rgb) {
const blkThrs = 0.022;
const blkClmp = 1.414;
// linearize & gamma correction
let r = (rgb[0] / 255) ** 2.4;
let g = (rgb[1] / 255) ** 2.4;
let b = (rgb[2] / 255) ** 2.4;
// relative luminance sRGB
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
// black clamp
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp;
// SAPC with white text
let contrastLight = bg ** 0.65 - 1;
// SAPC with black text
let contrastDark = bg ** 0.56 - 0.046134502;
if (Math.abs(contrastLight) >= Math.abs(contrastDark)) {
return 'light';
} else {
return 'dark';
}
},
getTagElement: function(string, color_overrides) {
let rgb = color_overrides?.[string] || Proxmox.Utils.stringToRGB(string);
let style = `background-color: ${Proxmox.Utils.rgbToCss(rgb)};`;
let cls;
if (rgb.length > 3) {
style += `color: ${Proxmox.Utils.rgbToCss([rgb[3], rgb[4], rgb[5]])}`;
cls = "proxmox-tag-dark";
} else {
let txtCls = Proxmox.Utils.getTextContrastClass(rgb);
cls = `proxmox-tag-${txtCls}`;
}
return `<span class="${cls}" style="${style}">${string}</span>`;
},
// Setting filename here when downloading from a remote url sometimes fails in chromium browsers
// because of a bug when using attribute download in conjunction with a self signed certificate.
// For more info see https://bugs.chromium.org/p/chromium/issues/detail?id=993362
downloadAsFile: function(source, fileName) {
let hiddenElement = document.createElement('a');
hiddenElement.href = source;
hiddenElement.target = '_blank';
if (fileName) {
hiddenElement.download = fileName;
}
hiddenElement.click();
},
},
singleton: true,
constructor: function() {
let me = this;
Ext.apply(me, me.utilities);
let IPV4_OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])";
let IPV4_REGEXP = "(?:(?:" + IPV4_OCTET + "\\.){3}" + IPV4_OCTET + ")";
let IPV6_H16 = "(?:[0-9a-fA-F]{1,4})";
let IPV6_LS32 = "(?:(?:" + IPV6_H16 + ":" + IPV6_H16 + ")|" + IPV4_REGEXP + ")";
let IPV4_CIDR_MASK = "([0-9]{1,2})";
let IPV6_CIDR_MASK = "([0-9]{1,3})";
me.IP4_match = new RegExp("^(?:" + IPV4_REGEXP + ")$");
me.IP4_cidr_match = new RegExp("^(?:" + IPV4_REGEXP + ")/" + IPV4_CIDR_MASK + "$");
/* eslint-disable no-useless-concat,no-multi-spaces */
let IPV6_REGEXP = "(?:" +
"(?:(?:" + "(?:" + IPV6_H16 + ":){6})" + IPV6_LS32 + ")|" +
"(?:(?:" + "::" + "(?:" + IPV6_H16 + ":){5})" + IPV6_LS32 + ")|" +
"(?:(?:(?:" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){4})" + IPV6_LS32 + ")|" +
"(?:(?:(?:(?:" + IPV6_H16 + ":){0,1}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){3})" + IPV6_LS32 + ")|" +
"(?:(?:(?:(?:" + IPV6_H16 + ":){0,2}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){2})" + IPV6_LS32 + ")|" +
"(?:(?:(?:(?:" + IPV6_H16 + ":){0,3}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){1})" + IPV6_LS32 + ")|" +
"(?:(?:(?:(?:" + IPV6_H16 + ":){0,4}" + IPV6_H16 + ")?::" + ")" + IPV6_LS32 + ")|" +
"(?:(?:(?:(?:" + IPV6_H16 + ":){0,5}" + IPV6_H16 + ")?::" + ")" + IPV6_H16 + ")|" +
"(?:(?:(?:(?:" + IPV6_H16 + ":){0,7}" + IPV6_H16 + ")?::" + ")" + ")" +
")";
/* eslint-enable no-useless-concat,no-multi-spaces */
me.IP6_match = new RegExp("^(?:" + IPV6_REGEXP + ")$");
me.IP6_cidr_match = new RegExp("^(?:" + IPV6_REGEXP + ")/" + IPV6_CIDR_MASK + "$");
me.IP6_bracket_match = new RegExp("^\\[(" + IPV6_REGEXP + ")\\]");
me.IP64_match = new RegExp("^(?:" + IPV6_REGEXP + "|" + IPV4_REGEXP + ")$");
me.IP64_cidr_match = new RegExp("^(?:" + IPV6_REGEXP + "/" + IPV6_CIDR_MASK + ")|(?:" + IPV4_REGEXP + "/" + IPV4_CIDR_MASK + ")$");
let DnsName_REGEXP = "(?:(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9\\-]*[a-zA-Z0-9])?)\\.)*(?:[A-Za-z0-9](?:[A-Za-z0-9\\-]*[A-Za-z0-9])?))";
me.DnsName_match = new RegExp("^" + DnsName_REGEXP + "$");
me.DnsName_or_Wildcard_match = new RegExp("^(?:\\*\\.)?" + DnsName_REGEXP + "$");
me.CpuSet_match = /^[0-9]+(?:-[0-9]+)?(?:,[0-9]+(?:-[0-9]+)?)*$/;
me.HostPort_match = new RegExp("^(" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")(?::(\\d+))?$");
me.HostPortBrackets_match = new RegExp("^\\[(" + IPV6_REGEXP + "|" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")\\](?::(\\d+))?$");
me.IP6_dotnotation_match = new RegExp("^(" + IPV6_REGEXP + ")(?:\\.(\\d+))?$");
me.Vlan_match = /^vlan(\d+)/;
me.VlanInterface_match = /(\w+)\.(\d+)/;
},
});
Ext.define('Proxmox.Async', {
singleton: true,
// Returns a Promise resolving to the result of an `API2Request` or rejecting to the error
// response on failure
api2: function(reqOpts) {
return new Promise((resolve, reject) => {
delete reqOpts.callback; // not allowed in this api
reqOpts.success = response => resolve(response);
reqOpts.failure = response => reject(response);
Proxmox.Utils.API2Request(reqOpts);
});
},
// Delay for a number of milliseconds.
sleep: function(millis) {
return new Promise((resolve, _reject) => setTimeout(resolve, millis));
},
});
Ext.override(Ext.data.Store, {
// If the store's proxy is changed while it is waiting for an AJAX
// response, `onProxyLoad` will still be called for the outdated response.
// To avoid displaying inconsistent information, only process responses
// belonging to the current proxy. However, do not apply this workaround
// to the mobile UI, as Sencha Touch has an incompatible internal API.
onProxyLoad: function(operation) {
let me = this;
if (Proxmox.Utils.toolkit === 'touch' || operation.getProxy() === me.getProxy()) {
me.callParent(arguments);
} else {
console.log(`ignored outdated response: ${operation.getRequest().getUrl()}`);
}
},
});