🐶 Sync 2026-10-20 22:40:23

This commit is contained in:
github-actions[bot] committed 2026-10-20 22:40:23 +08:00
1 parent e813f5cc54
commit 64cea2243a
216 files changed
+11913 -2968

No files matched your search

@@ -0,0 +1,27 @@
'use strict';
'require baseclass';
return baseclass.extend({
parseAutoJoin: function(val) {
if (!val || !val.trim())
return { channels: [], keys: [] };
var trimmed = val.trim();
var spaceIdx = trimmed.search(/\s/);
if (spaceIdx === -1) {
return {
channels: trimmed.split(',').map(function(c) { return c.trim(); }).filter(Boolean),
keys: []
};
}
var firstPart = trimmed.substring(0, spaceIdx).trim();
var rest = trimmed.substring(spaceIdx + 1).trim();
/* Keys are positional, so keep empty ones to stay aligned with channels */
return {
channels: firstPart.split(',').map(function(c) { return c.trim(); }).filter(Boolean),
keys: rest.length ? rest.split(',').map(function(k) { return k.trim(); }) : []
};
}
});
@@ -0,0 +1,412 @@
'use strict';
'require view';
'require form';
'require rpc';
'require uci';
'require ui';
var callGetCertStatus = rpc.declare({
object: 'weechat',
method: 'get_certificate_status',
params: [ 'path' ],
expect: { '': {} }
});
var callGenerateCert = rpc.declare({
object: 'weechat',
method: 'generate_certificate',
params: [ 'cn', 'san', 'path' ],
expect: { '': {} }
});
var callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: [ 'name' ],
expect: { '': {} }
});
var callRcInit = rpc.declare({
object: 'rc',
method: 'init',
params: [ 'name', 'action' ]
});
/* Mirrors check_path() in the init script, which refuses to start otherwise */
function validatePath(value, allowPercent) {
if (!value)
return true;
if (value.charAt(0) !== '/' || /[\r\n]/.test(value) || /(^|\/)\.\.?(\/|$)/.test(value))
return _('Must be an absolute path without . or .. components');
if (value !== value.trim())
return _('Must not start or end with a space');
if (value.indexOf('${') !== -1)
return _('Must not contain "${"');
if (!allowPercent && value.indexOf('%') !== -1)
return _('Must not contain "%"');
return true;
}
/* WeeChat only reads the certificate when it starts, so restart a running relay */
function restartIfRunning() {
return callServiceList('weechat').then(function(res) {
var instances = (res && res.weechat && res.weechat.instances) || {};
var running = Object.keys(instances).some(function(k) { return instances[k].running; });
if (!running)
return false;
return callRcInit('weechat', 'restart').then(function(ret) {
if (ret)
throw new Error(_('Command failed with return code %d').format(ret));
return true;
});
});
}
return view.extend({
load: function() {
return uci.load('weechat');
},
render: function() {
var m, s, o;
var defaultPath = '/etc/weechat/tls/relay.pem';
var refreshCertStatus = null;
/* Only certificates in this directory are checked and generated here */
function isManagedPath(path) {
return /^\/etc\/weechat\/tls\/[A-Za-z0-9._-]+\.pem$/.test(path) && path.indexOf('..') === -1;
}
/* IPv6 literals come with brackets, which are not valid in a SAN */
var hostname = (window.location.hostname || 'OpenWrt').replace(/^\[(.*)\]$/, '$1');
var defaultSan = hostname;
m = new form.Map('weechat', _('WeeChat - Relay'),
_('Configure the WeeChat relay and chat logging.'));
/* Relay */
s = m.section(form.NamedSection, 'weechat', 'weechat', _('Relay'));
var optRelayEnabled = s.option(form.Flag, 'relay_enabled', _('Enable Relay Server'));
optRelayEnabled.default = optRelayEnabled.disabled;
optRelayEnabled.rmempty = false;
o = s.option(form.Value, 'relay_address', _('Listen Address'),
_('IP address to bind (leave empty for all interfaces). The address must exist when WeeChat starts.'));
o.datatype = 'ipaddr("nomask")';
o.placeholder = _('All interfaces');
/* WeeChat cannot bind to it and the init script refuses it */
o.validate = function(section_id, value) {
var m = (value || '').match(/(?:^|:)(\d+\.\d+\.\d+\.\d+)$/);
if (m && m[1].split('.').some(function(p) { return p.length > 1 && p.charAt(0) === '0'; }))
return _('Leading zeros are not allowed in an IPv4 address');
return true;
};
o.rmempty = true;
o.depends('relay_enabled', '1');
o = s.option(form.Value, 'relay_port', _('Port'),
_('Port for relay connections (WebSocket).'));
o.datatype = 'and(port,min(1))';
o.default = '9000';
o.placeholder = '9000';
o.depends('relay_enabled', '1');
/* Security */
s = m.section(form.NamedSection, 'weechat', 'weechat', _('Security'));
o = s.option(form.Flag, 'relay_tls', _('Enable TLS'),
_('Encrypt relay connections with TLS.'));
o.default = o.disabled;
var optCertKey = s.option(form.Value, 'relay_cert_key', _('Certificate'),
_('PEM file containing the certificate and private key. It must be readable by the weechat user.'));
optCertKey.placeholder = defaultPath;
optCertKey.rmempty = true;
optCertKey.depends('relay_tls', '1');
optCertKey.validate = function(section_id, value) {
return validatePath(value, true);
};
optCertKey.onchange = function() {
if (refreshCertStatus)
refreshCertStatus();
};
/* An empty field means the default path, as in the init script */
function getActiveCertPath() {
var val = optCertKey.formvalue('weechat');
if (val == null)
val = uci.get('weechat', 'weechat', 'relay_cert_key');
return (val || '').trim() || defaultPath;
}
/* Certificate status widget */
var optCertStatus = s.option(form.DummyValue, '_cert_status', _('Certificate status'));
optCertStatus.depends('relay_tls', '1');
optCertStatus.renderWidget = function(section_id, option_index, cfgvalue) {
var container = E('div', { 'class': 'cbi-value-field' });
var isReadonly = (this.readonly != null) ? this.readonly : this.map.readonly;
function updateWidget(path) {
while (container.firstChild)
container.removeChild(container.firstChild);
if (!isManagedPath(path)) {
container.appendChild(E('div', { 'class': 'alert-message notice' },
_('This certificate is managed outside of LuCI, so it is not checked here. The service refuses to start if the file does not exist.')));
return;
}
container.appendChild(E('em', {}, _('Checking certificate status…')));
callGetCertStatus(path || defaultPath).then(function(res) {
while (container.firstChild)
container.removeChild(container.firstChild);
var elements = [];
if (res && res.exists && res.has_cert && res.has_key) {
elements.push(E('div', {}, [
E('span', { 'class': 'label success' }, [ '● ', _('Certificate and key present') ])
]));
elements.push(E('p', { 'class': 'cbi-value-description' }, [
E('strong', {}, _('File: ')),
E('code', {}, res.path)
]));
elements.push(E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'disabled': isReadonly || null,
'click': function(ev) {
ev.preventDefault();
showGenerateDialog(getActiveCertPath(), res.generator_available);
}
}, _('Generate new certificate')));
} else if (res && res.exists && (!res.has_cert || !res.has_key)) {
elements.push(E('div', { 'class': 'alert-message warning' },
_('Certificate file exists but is incomplete (must contain both certificate and private key).')));
elements.push(E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'disabled': isReadonly || null,
'click': function(ev) {
ev.preventDefault();
showGenerateDialog(getActiveCertPath(), res.generator_available);
}
}, _('Generate self-signed certificate')));
} else {
elements.push(E('div', { 'class': 'alert-message warning' },
_('TLS is enabled but no certificate file was found.')));
elements.push(E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'disabled': isReadonly || null,
'click': function(ev) {
ev.preventDefault();
showGenerateDialog(getActiveCertPath(), res && res.generator_available);
}
}, _('Generate self-signed certificate')));
}
if (res && res.generator_available === false) {
elements.push(E('div', { 'class': 'alert-message warning' },
_('Certificate generation requires the %s or %s utility.').format('px5g', 'openssl')));
}
for (var i = 0; i < elements.length; i++)
container.appendChild(elements[i]);
}).catch(function(err) {
while (container.firstChild)
container.removeChild(container.firstChild);
container.appendChild(E('div', { 'class': 'alert-message error' },
_('Unable to verify certificate status: %s').format(err.message || err)));
});
}
function showGenerateDialog(targetPath, generatorAvailable) {
var inputCn, inputSan, btnGenerate, btnCancel, msgBox;
var modalContent = [
E('div', { 'class': 'alert-message notice' }, [
E('p', {},
_('A self-signed certificate will be generated for WeeChat Relay at %s.').format(targetPath)),
E('p', {},
E('strong', {}, _('Self-signed certificates provide encryption but are not trusted automatically by browsers.'))),
E('p', {},
_('For Internet-facing deployments, use a certificate issued by a trusted CA.'))
]),
E('h5', {}, _('Certificate identity')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Common name')),
E('div', { 'class': 'cbi-value-field' }, [
inputCn = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'value': hostname,
'placeholder': 'router.local'
}),
E('div', { 'class': 'cbi-value-description' },
_('Host name identifying this router.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Subject alternative names')),
E('div', { 'class': 'cbi-value-field' }, [
inputSan = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'value': defaultSan,
'placeholder': 'router.local, 192.168.1.1'
}),
E('div', { 'class': 'cbi-value-description' },
_('Comma-separated list of host names and IP addresses used to reach the relay.'))
])
]),
msgBox = E('div', {}),
E('div', { 'class': 'right' }, [
btnCancel = E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
' ',
btnGenerate = E('button', {
'class': 'btn cbi-button cbi-button-action',
'click': function(ev) {
ev.preventDefault();
var cnVal = inputCn.value.trim();
var sanVal = inputSan.value.trim();
if (!cnVal) {
ui.addNotification(null, E('p', _('Please enter a valid Common Name.')), 'warning');
return;
}
btnGenerate.disabled = true;
btnCancel.disabled = true;
btnGenerate.textContent = _('Generating…');
while (msgBox.firstChild)
msgBox.removeChild(msgBox.firstChild);
msgBox.appendChild(E('p', { 'class': 'spinning' }, _('Generating self-signed certificate…')));
callGenerateCert(cnVal, sanVal, targetPath).then(function(res) {
if (res && res.success) {
ui.hideModal();
updateWidget(getActiveCertPath());
/* Only the saved relay settings decide which certificate WeeChat loads */
(res.in_use ? restartIfRunning() : Promise.resolve(null)).then(function(restarted) {
var msg;
if (restarted === true)
msg = _('Self-signed certificate generated for %s. WeeChat was restarted to use it.');
else if (restarted === false)
msg = _('Self-signed certificate generated for %s. WeeChat uses it from its next start.');
else
msg = _('Self-signed certificate generated for %s. Save and apply TLS with this certificate to use it.');
ui.addNotification(null, E('p', msg.format(cnVal)), 'info');
}).catch(function(err) {
ui.addNotification(null, E('p',
_('Self-signed certificate generated for %s, but WeeChat could not be restarted: %s').format(cnVal, err.message || err)),
'warning');
});
} else {
btnGenerate.disabled = false;
btnCancel.disabled = false;
btnGenerate.textContent = _('Generate');
while (msgBox.firstChild)
msgBox.removeChild(msgBox.firstChild);
msgBox.appendChild(E('div', { 'class': 'alert-message error' },
res && res.error ? res.error : _('Certificate generation failed.')));
}
}).catch(function(err) {
btnGenerate.disabled = false;
btnCancel.disabled = false;
btnGenerate.textContent = _('Generate');
while (msgBox.firstChild)
msgBox.removeChild(msgBox.firstChild);
msgBox.appendChild(E('div', { 'class': 'alert-message error' },
err.message || err));
});
}
}, _('Generate'))
])
];
if (generatorAvailable === false) {
btnGenerate.disabled = true;
msgBox.appendChild(E('div', { 'class': 'alert-message error' },
_('Cannot generate certificate: neither %s nor %s is installed on this router.').format('px5g', 'openssl')));
}
ui.showModal(_('Generate certificate?'), modalContent);
}
refreshCertStatus = function() {
updateWidget(getActiveCertPath());
};
/* The field is not in the page yet while rendering, start from its stored value */
updateWidget(String(optCertKey.cfgvalue(section_id) || '').trim() || defaultPath);
return container;
};
o = s.option(form.Value, 'relay_password', _('Relay Password'),
_('Password required by clients to connect.'));
o.password = true;
o.validate = function(section_id, value) {
var formVal = optRelayEnabled ? optRelayEnabled.formvalue(section_id) : null;
var isEnabled = (formVal !== null) ? (formVal === '1') : (uci.get('weechat', section_id, 'relay_enabled') === '1');
if (isEnabled && (!value || !value.trim())) {
return _('Relay password is required when relay server is enabled.');
}
return true;
};
/* Logging */
s = m.section(form.NamedSection, 'weechat', 'weechat', _('Logging'));
o = s.option(form.Flag, 'log_enabled', _('Enable Logging'),
_('Record chat logs to disk. Disabled by default to protect flash memory.'));
o.default = o.disabled;
o = s.option(form.Value, 'log_dir', _('Log Directory'),
_('Directory where WeeChat stores chat logs. The default /var/log/weechat is created automatically, any other directory must exist and be writable by the weechat user.'));
o.placeholder = '/var/log/weechat';
o.depends('log_enabled', '1');
o.validate = function(section_id, value) {
return validatePath(value, false);
};
/* Web Client */
s = m.section(form.NamedSection, 'weechat', 'weechat', _('Web Client'));
o = s.option(form.DummyValue, '_gb_info');
o.cfgvalue = function() {
return E('div', {}, [
E('p', { 'style': 'margin-bottom: 12px; line-height: 1.5;' },
_('Glowing Bear is a web client that connects to WeeChat using the relay configured above.')),
E('a', {
'class': 'btn cbi-button cbi-button-action',
'href': 'https://www.glowing-bear.org/',
'target': '_blank',
'rel': 'noreferrer noopener',
'style': 'margin-bottom: 6px; display: inline-block;'
}, [ _('Open Glowing Bear (Hosted)'), ' ↗' ])
]);
};
return m.render();
}
});
@@ -0,0 +1,376 @@
'use strict';
'require view';
'require form';
'require uci';
'require ui';
'require tools.weechat as weechatTools';
/* WeeChat evaluates these options, the init script refuses "${" in them */
function validateNoExpr(section_id, value) {
if (value && value.indexOf('${') !== -1)
return _('Must not contain "${"');
return true;
}
function checkAutoJoinRows(rows) {
for (var i = 0; i < rows.length; i++) {
var chanInput = rows[i].querySelector('.chan-input');
var keyInput = rows[i].querySelector('.key-input');
var chan = (chanInput ? chanInput.value : '').trim();
var key = (keyInput ? keyInput.value : '').trim();
if (!chan && key)
return _('Channel key cannot be specified without a channel name');
if (chan && (chan.indexOf(',') !== -1 || /\s/.test(chan)))
return _('Channel name "%s" cannot contain commas or spaces').format(chan);
if (key && (key.indexOf(',') !== -1 || /\s/.test(key)))
return _('Channel key cannot contain commas or spaces');
if (validateNoExpr(null, chan) !== true || validateNoExpr(null, key) !== true)
return _('Channels and keys must not contain "${"');
}
return true;
}
var CBIAutoJoin = form.Value.extend({
validate: function(section_id, value) {
var el = document.getElementById(this.cbid(section_id));
if (!el || !el.parentNode)
return true;
var res = checkAutoJoinRows(el.parentNode.querySelectorAll('tr[data-channel-row]'));
/* The modal shows no message for this widget, so show it here */
var box = el.parentNode.querySelector('.autojoin-error');
if (box) {
box.textContent = (res === true) ? '' : res;
box.style.display = (res === true) ? 'none' : '';
}
return res;
},
/*
* The value lives in a hidden field, which never runs validation on its
* own, so let parse() see the result of validate() through these.
*/
isValid: function(section_id) {
return this.validate(section_id, this.formvalue(section_id)) === true;
},
getValidationError: function(section_id) {
var res = this.validate(section_id, this.formvalue(section_id));
return (res === true) ? '' : res;
},
textvalue: function(section_id) {
var val = this.cfgvalue(section_id);
var parsed = weechatTools.parseAutoJoin(val);
if (parsed.channels.length === 0)
return null;
var nodes = [];
parsed.channels.forEach(function(chan, idx) {
if (idx > 0)
nodes.push(', ');
nodes.push(E('code', {}, chan));
});
return E('span', {}, nodes);
},
renderWidget: function(section_id, option_index, cfgvalue) {
var isReadonly = (this.readonly != null) ? this.readonly : this.map.readonly;
var hidden = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : '', {
id: this.cbid(section_id)
});
var table = E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr cbi-section-table-titles' }, [
E('th', { 'class': 'th cbi-section-table-cell' }, _('Channel')),
E('th', { 'class': 'th cbi-section-table-cell' }, _('Key')),
E('th', { 'class': 'th cbi-section-table-cell cbi-section-actions' }, '')
])
]);
function updateValue() {
var rows = table.querySelectorAll('tr[data-channel-row]');
var chansWithKeys = [];
var chansWithoutKeys = [];
rows.forEach(function(row) {
var chanInput = row.querySelector('.chan-input');
var keyInput = row.querySelector('.key-input');
var chan = (chanInput ? chanInput.value : '').trim();
var key = (keyInput ? keyInput.value : '').trim();
if (!chan)
return;
if (key)
chansWithKeys.push({ chan: chan, key: key });
else
chansWithoutKeys.push(chan);
});
var allChans = chansWithKeys.map(function(item) { return item.chan; }).concat(chansWithoutKeys);
var allKeys = chansWithKeys.map(function(item) { return item.key; });
var result = allChans.join(',');
if (allKeys.length > 0)
result += ' ' + allKeys.join(',');
hidden.setValue(result);
}
function createRow(channel, key) {
var chanInput = E('input', {
'type': 'text',
'class': 'cbi-input-text chan-input',
'placeholder': '#openwrt',
'value': channel || '',
'disabled': isReadonly || null
});
chanInput.addEventListener('input', function() {
if (chanInput.value.indexOf(',') !== -1 || /\s/.test(chanInput.value)) {
chanInput.classList.add('cbi-input-invalid');
chanInput.title = _('Enter a single channel per row without commas or spaces');
} else {
chanInput.classList.remove('cbi-input-invalid');
chanInput.removeAttribute('title');
}
updateValue();
});
var keyInput = E('input', {
'type': 'password',
'class': 'cbi-input-text key-input',
'placeholder': _('Optional key'),
'value': key || '',
'disabled': isReadonly || null
});
keyInput.addEventListener('input', function() {
if (keyInput.value.indexOf(',') !== -1 || /\s/.test(keyInput.value)) {
keyInput.classList.add('cbi-input-invalid');
keyInput.title = _('Channel key cannot contain commas or spaces');
} else {
keyInput.classList.remove('cbi-input-invalid');
keyInput.removeAttribute('title');
}
updateValue();
});
var btnRemove = E('button', {
'type': 'button',
'class': 'btn cbi-button cbi-button-remove',
'title': _('Remove'),
'disabled': isReadonly || null
}, '✕');
var row = E('tr', {
'class': 'tr cbi-section-table-row',
'data-channel-row': ''
}, [
E('td', { 'class': 'td cbi-section-table-cell' }, chanInput),
E('td', { 'class': 'td cbi-section-table-cell' }, keyInput),
E('td', { 'class': 'td cbi-section-table-cell cbi-section-actions' }, btnRemove)
]);
btnRemove.addEventListener('click', function() {
row.remove();
updateValue();
});
return row;
}
var parsed = weechatTools.parseAutoJoin(cfgvalue != null ? cfgvalue : '');
if (parsed.channels.length > 0) {
parsed.channels.forEach(function(chan, idx) {
table.appendChild(createRow(chan, parsed.keys[idx] || ''));
});
}
else {
table.appendChild(createRow('', ''));
}
var btnAdd = E('button', {
'type': 'button',
'class': 'btn cbi-button cbi-button-add',
'disabled': isReadonly || null
}, _('Add channel'));
btnAdd.addEventListener('click', function() {
var newRow = createRow('', '');
table.appendChild(newRow);
var input = newRow.querySelector('.chan-input');
if (input)
input.focus();
});
return E('div', { 'class': 'cbi-value-field' }, [
table,
btnAdd,
E('div', { 'class': 'alert-message error autojoin-error', 'style': 'display: none' }),
hidden.render()
]);
}
});
return view.extend({
render: function() {
var m, s, o;
m = new form.Map('weechat', _('WeeChat - IRC Servers'),
_('Configure IRC server connections, authentication, and auto-join channels.'));
s = m.section(form.GridSection, 'server', _('IRC Servers'));
s.addremove = true;
s.anonymous = false;
s.addbtntitle = _('Add Server');
s.nodescriptions = true;
s.sectiontitle = function(section_id) {
return section_id;
};
/* Define Tabs */
s.tab('connection', _('Connection'));
s.tab('identity', _('Identity'));
s.tab('auth', _('Authentication'));
s.tab('channels', _('Channels'));
/* Connection settings (table columns + modal Connection tab) */
o = s.taboption('connection', form.Value, 'address', _('Server Hostname'),
_('Hostname or IP address of the IRC server.'));
o.placeholder = 'irc.oftc.net';
o.datatype = 'host';
o.rmempty = false;
o = s.taboption('connection', form.Value, 'port', _('Port'));
o.datatype = 'and(port,min(1))';
o.default = '6697';
o.placeholder = '6697';
o = s.taboption('connection', form.Flag, 'ssl', _('Use TLS'));
o.default = o.enabled;
o.rmempty = false;
o = s.taboption('connection', form.ListValue, 'ipv6', _('IPv6 Support'),
_('IPv6 connection mode for this IRC server.'));
o.value('auto', _('Auto'));
o.value('force', _('Force IPv6'));
o.value('disable', _('Disable IPv6'));
o.default = 'auto';
o.textvalue = function(section_id) {
var val = this.cfgvalue(section_id) || this.default;
switch (val) {
case 'force': return _('Force IPv6');
case 'disable': return _('Disabled');
case 'auto':
default:
return _('Auto');
}
};
/* Channels (table column + modal Channels tab) */
o = s.taboption('channels', CBIAutoJoin, 'autojoin', _('Auto-join Channels'),
_('Channels to join automatically after connecting to this server.'));
o.rmempty = true;
/* Auto-connect & Enabled flags (table columns + modal Connection tab) */
o = s.taboption('connection', form.Flag, 'autoconnect', _('Auto-connect'),
_('Connect automatically when WeeChat starts.'));
o.default = o.enabled;
o.rmempty = false;
o.editable = true;
o = s.taboption('connection', form.Flag, 'enabled', _('Enabled'));
o.default = o.enabled;
o.rmempty = false;
o.editable = true;
/* Tab 2: Identity */
o = s.taboption('identity', form.Value, 'nicks', _('Nicknames'),
_('Comma-separated list of nicknames to try.'));
o.placeholder = 'weechat,weechat_';
o.modalonly = true;
o.validate = validateNoExpr;
o = s.taboption('identity', form.Value, 'username', _('Username'));
o.placeholder = 'weechat';
o.modalonly = true;
o.validate = validateNoExpr;
o = s.taboption('identity', form.Value, 'realname', _('Real Name'));
o.placeholder = 'WeeChat User';
o.modalonly = true;
o.validate = validateNoExpr;
/* Tab 3: Authentication */
o = s.taboption('auth', form.ListValue, 'auth_method', _('Authentication Method'),
_('Select the authentication method required by the IRC network.'));
o.value('none', _('None'));
o.value('nickserv', _('NickServ (/msg nickserv identify)'));
o.value('sasl_plain', _('SASL PLAIN'));
o.value('sasl_scram_sha_256', _('SASL SCRAM-SHA-256'));
o.value('sasl_scram_sha_512', _('SASL SCRAM-SHA-512'));
o.default = 'none';
o.modalonly = true;
o = s.taboption('auth', form.Value, 'sasl_username', _('SASL Username'),
_('Account username if different from nickname.'));
o.depends('auth_method', 'sasl_plain');
o.depends('auth_method', 'sasl_scram_sha_256');
o.depends('auth_method', 'sasl_scram_sha_512');
o.modalonly = true;
/* Same default as the init script: the first nickname, then the username */
o.validate = function(section_id, value) {
var nick = (this.section.formvalue(section_id, 'nicks') || '').split(',')[0];
var account = value || nick || this.section.formvalue(section_id, 'username') || '';
if (!account.trim())
return _('Enter a SASL username or a nickname');
return validateNoExpr(section_id, value);
};
o = s.taboption('auth', form.Value, 'password', _('Password'));
o.password = true;
o.depends('auth_method', 'nickserv');
o.depends('auth_method', 'sasl_plain');
o.depends('auth_method', 'sasl_scram_sha_256');
o.depends('auth_method', 'sasl_scram_sha_512');
o.modalonly = true;
o.validate = function(section_id, value) {
var method = this.section.formvalue(section_id, 'auth_method');
if (!method || method === 'none')
return true;
if (!value || !value.trim())
return _('A password is required for this authentication method');
/* WeeChat replaces these in the NickServ command after decoding the password */
if (method === 'nickserv' && /\$(nick|channel|server)/.test(value))
return _('A NickServ password cannot contain $nick, $channel or $server');
return true;
};
return m.render().then(L.bind(function(nodes) {
var target = null;
var hashMatch = (window.location.hash || '').match(/#(?:edit|server)=([^&]+)/);
if (hashMatch) {
target = decodeURIComponent(hashMatch[1]);
} else {
var params = new URLSearchParams(window.location.search);
target = params.get('server') || params.get('edit');
}
if (target && (m.data.get('weechat', target) || uci.get('weechat', target))) {
if (window.history && window.history.replaceState) {
var cleanSearch = window.location.search ? window.location.search.replace(/[?&](?:server|edit)=[^&]*/g, '').replace(/^&/, '?') : '';
window.history.replaceState(null, '', window.location.pathname + cleanSearch);
}
s.renderMoreOptionsModal(target);
}
return nodes;
}, this));
}
});
@@ -0,0 +1,394 @@
'use strict';
'require view';
'require rpc';
'require uci';
'require ui';
'require poll';
'require tools.weechat as weechatTools';
function renderEnabledState(isEnabled) {
return [
isEnabled
? E('span', { 'class': 'label success', 'style': 'margin-right: 8px;' }, _('Enabled'))
: E('span', { 'class': 'label danger', 'style': 'margin-right: 8px;' }, _('Disabled')),
E('span', { 'class': 'cbi-value-description' },
isEnabled ? _('Service is allowed to start.') : _('Service is disabled in configuration.'))
];
}
function getServiceStatus(serviceData) {
var isRunning = false,
pid = null;
if (serviceData && serviceData.weechat && serviceData.weechat.instances) {
var instances = serviceData.weechat.instances;
for (var inst in instances) {
if (instances[inst].running) {
isRunning = true;
pid = instances[inst].pid;
break;
}
}
}
return { running: isRunning, pid: pid };
}
return view.extend({
callServiceList: rpc.declare({
object: 'service',
method: 'list',
params: ['name'],
expect: { '': {} }
}),
callRcList: rpc.declare({
object: 'rc',
method: 'list',
expect: { '': {} }
}),
callRcInit: rpc.declare({
object: 'rc',
method: 'init',
params: ['name', 'action']
}),
callDaemonInfo: rpc.declare({
object: 'weechat',
method: 'get_daemon_info',
expect: { '': {} }
}),
callEnableService: rpc.declare({
object: 'weechat',
method: 'enable_service',
expect: { success: false }
}),
/*
* The init script only starts WeeChat with option enabled '1'. Let the
* backend commit just that option, uci.apply() would apply every change
* staged in this session, for any config.
*/
ensureEnabled: function() {
if (uci.get('weechat', 'weechat', 'enabled') === '1')
return Promise.resolve();
return this.callEnableService().then(function(success) {
if (!success)
throw new Error(_('Could not enable the service in /etc/config/weechat'));
var cell = document.getElementById('weechat-service-enabled');
if (cell) {
while (cell.firstChild)
cell.removeChild(cell.firstChild);
renderEnabledState(true).forEach(function(node) { cell.appendChild(node); });
}
uci.unload('weechat');
return uci.load('weechat');
});
},
load: function() {
return Promise.all([
uci.load('weechat'),
this.callServiceList('weechat'),
this.callRcList(),
L.resolveDefault(this.callDaemonInfo(), {})
]);
},
handleServiceAction: function(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, action) {
btnStart.disabled = true;
btnRestart.disabled = true;
btnStop.disabled = true;
var ensureEnabled = (action === 'start') ? this.ensureEnabled() : Promise.resolve();
return ensureEnabled.then(L.bind(function() {
return this.callRcInit('weechat', action);
}, this)).then(L.bind(function(ret) {
if (ret)
throw new Error(_('Command failed with return code %d').format(ret));
return new Promise(function(resolve) {
window.setTimeout(resolve, 1000);
});
}, this)).then(L.bind(function() {
return this.updateServiceStatus(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly);
}, this)).catch(function(e) {
ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/weechat %s": %s').format(action, e.message || e)));
});
},
handleToggleAutostart: function(enable, autostartBadge, autostartBtn, isReadonly, ev) {
var action = enable ? 'enable' : 'disable';
autostartBtn.disabled = true;
var prepare = enable ? this.ensureEnabled() : Promise.resolve();
return prepare.then(L.bind(function() {
return this.callRcInit('weechat', action);
}, this)).then(L.bind(function(ret) {
if (ret)
throw new Error(_('Command failed with return code %d').format(ret));
autostartBadge.className = 'label ' + (enable ? 'success' : '');
autostartBadge.textContent = enable ? _('Enabled') : _('Disabled');
autostartBtn.textContent = enable ? _('Disable Autostart') : _('Enable Autostart');
autostartBtn.disabled = isReadonly ? true : false;
autostartBtn.onclick = isReadonly ? null : ui.createHandlerFn(this, 'handleToggleAutostart', !enable, autostartBadge, autostartBtn, isReadonly);
}, this)).catch(function(e) {
autostartBtn.disabled = false;
ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/weechat %s": %s').format(action, e.message || e)));
});
},
updateServiceStatus: function(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly) {
return this.callServiceList('weechat').then(function(res) {
var st = getServiceStatus(res);
var isRunning = st.running;
var pid = st.pid;
statusBadge.className = 'label ' + (isRunning ? 'success' : 'danger');
statusBadge.textContent = isRunning ? _('Running') : _('Stopped');
pidLabel.textContent = (isRunning && pid) ? ('PID: ' + pid) : '';
btnStart.disabled = (isRunning || isReadonly);
btnRestart.disabled = (!isRunning || isReadonly);
btnStop.disabled = (!isRunning || isReadonly);
});
},
render: function(data) {
var serviceData = data[1] || {},
rcData = data[2] || {},
daemonInfo = data[3] || {},
isReadonly = !L.hasViewPermission(),
st = getServiceStatus(serviceData),
isRunning = st.running,
pid = st.pid,
isAutostart = !!(rcData && rcData.weechat && rcData.weechat.enabled),
isEnabled = (uci.get('weechat', 'weechat', 'enabled') === '1'),
version = (daemonInfo && daemonInfo.version) ? daemonInfo.version : '-';
var statusBadge = E('span', {
'class': 'label ' + (isRunning ? 'success' : 'danger'),
'style': 'padding: 4px 10px; font-size: 90%; font-weight: bold; border-radius: 4px;'
}, isRunning ? _('Running') : _('Stopped'));
var pidLabel = E('span', {
'class': 'cbi-value-description',
'style': 'margin-left: 8px; font-weight: normal;'
}, (isRunning && pid) ? ('PID: ' + pid) : '');
var autostartBadge = E('span', {
'class': 'label ' + (isAutostart ? 'success' : ''),
'style': 'margin-right: 12px;'
}, isAutostart ? _('Enabled') : _('Disabled'));
var autostartBtn = E('button', {
'class': 'btn cbi-button cbi-button-action',
'disabled': isReadonly ? 'disabled' : null
}, isAutostart ? _('Disable Autostart') : _('Enable Autostart'));
if (!isReadonly) {
autostartBtn.onclick = ui.createHandlerFn(this, 'handleToggleAutostart', !isAutostart, autostartBadge, autostartBtn, isReadonly);
}
var btnStart = E('button', {
'class': 'btn cbi-button cbi-button-action weechat-btn-control',
'disabled': (isRunning || isReadonly) ? 'disabled' : null
}, _('Start'));
var btnRestart = E('button', {
'class': 'btn cbi-button cbi-button-reload weechat-btn-control',
'disabled': (!isRunning || isReadonly) ? 'disabled' : null
}, _('Restart'));
var btnStop = E('button', {
'class': 'btn cbi-button cbi-button-negative weechat-btn-control',
'disabled': (!isRunning || isReadonly) ? 'disabled' : null
}, _('Stop'));
if (!isReadonly) {
btnStart.addEventListener('click',
ui.createHandlerFn(this, 'handleServiceAction',
statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, 'start'));
btnRestart.addEventListener('click',
ui.createHandlerFn(this, 'handleServiceAction',
statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, 'restart'));
btnStop.addEventListener('click',
ui.createHandlerFn(this, 'handleServiceAction',
statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, 'stop'));
}
var relayEnabled = (uci.get('weechat', 'weechat', 'relay_enabled') === '1'),
relayPort = uci.get('weechat', 'weechat', 'relay_port') || '9000',
relayTls = (uci.get('weechat', 'weechat', 'relay_tls') === '1');
var servers = uci.sections('weechat', 'server'),
enabledCount = 0;
servers.forEach(function(srv) {
if (srv.enabled !== '0' && srv.enabled !== false && srv.enabled !== 'off')
enabledCount++;
});
var relayStatusNode;
if (relayEnabled) {
var clientBtn = E('a', {
'class': 'btn cbi-button cbi-button-neutral',
'href': 'https://www.glowing-bear.org/',
'target': '_blank',
'rel': 'noreferrer noopener',
'style': 'display: inline-block; margin-top: 6px;'
}, [ _('Open Web Client (Hosted)'), ' ↗' ]);
relayStatusNode = E('div', {}, [
E('div', {}, [
E('span', { 'class': 'label success' }, _('Enabled'))
]),
E('div', { 'class': 'cbi-value-description', 'style': 'margin-top: 4px;' },
_('Port %s · TLS %s').format(relayPort, relayTls ? _('enabled') : _('disabled'))),
E('div', { 'style': 'margin-top: 6px;' }, clientBtn)
]);
} else {
relayStatusNode = E('div', {}, [
E('span', { 'class': 'label' }, _('Disabled'))
]);
}
var ircStatusNode = E('div', {}, [
E('span', {},
_('%d configured · %d enabled').format(servers.length, enabledCount))
]);
var serverRows = [];
if (servers.length === 0) {
serverRows.push(E('tr', { 'class': 'tr cbi-section-table-row' }, [
E('td', { 'colspan': 8, 'class': 'td cbi-section-table-cell center' },
_('No IRC servers configured. Go to Servers tab to add one.'))
]));
} else {
servers.forEach(function(s) {
var isSrvEnabled = (s.enabled !== '0' && s.enabled !== false && s.enabled !== 'off');
var isSrvSsl = (s.ssl !== '0' && s.ssl !== false && s.ssl !== 'off');
var parsed = weechatTools.parseAutoJoin(s.autojoin);
var chans = parsed.channels;
var chanNodes = [];
if (chans.length > 0) {
chans.forEach(function(chan) {
chanNodes.push(E('code', {
'style': 'margin: 2px 4px 2px 0; display: inline-block; padding: 2px 6px; border-radius: 3px;'
}, chan));
});
} else {
chanNodes.push(E('em', { 'class': 'cbi-value-description' }, _('no channels')));
}
serverRows.push(E('tr', { 'class': 'tr cbi-section-table-row' }, [
E('td', { 'class': 'td cbi-section-table-cell' },
isSrvEnabled
? E('span', { 'class': 'label success' }, _('Enabled'))
: E('span', { 'class': 'label' }, _('Disabled'))),
E('td', { 'class': 'td cbi-section-table-cell' }, E('strong', {}, s['.name'])),
E('td', { 'class': 'td cbi-section-table-cell' }, s.address || '-'),
E('td', { 'class': 'td cbi-section-table-cell' }, s.port || '6697'),
E('td', { 'class': 'td cbi-section-table-cell' }, isSrvSsl ? _('Yes') : _('No')),
E('td', { 'class': 'td cbi-section-table-cell' }, s.nicks || s.username || '-'),
E('td', { 'class': 'td cbi-section-table-cell' }, chanNodes),
E('td', { 'class': 'td cbi-section-table-cell cbi-section-actions', 'style': 'text-align: right; white-space: nowrap;' }, [
E('a', {
'class': 'btn cbi-button cbi-button-edit',
'href': L.url('admin/services/weechat/servers') + '?server=' + encodeURIComponent(s['.name']) + '#edit=' + encodeURIComponent(s['.name']),
'title': _('Edit server'),
'aria-label': _('Edit server')
}, '✎')
])
]));
});
}
poll.add(L.bind(function() {
return this.updateServiceStatus(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly);
}, this), 5);
return E('div', { 'class': 'cbi-map' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('view/weechat/weechat.css') }),
E('h2', {}, _('WeeChat - Status')),
E('div', { 'class': 'cbi-map-descr' },
_('WeeChat runs as a headless daemon on your router. It keeps you connected to IRC networks and exposes a secure Relay API for web or mobile clients.')),
/* Two cards grid */
E('div', { 'class': 'weechat-status-grid' }, [
/* Card 1: Service Status */
E('div', { 'class': 'weechat-panel' }, [
E('h3', {}, _('Service Status')),
E('table', {}, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'style': 'width: 35%;' }, _('WeeChat Daemon')),
E('td', { 'class': 'td left' }, [ statusBadge, pidLabel ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Version')),
E('td', { 'class': 'td left' }, E('span', {}, version))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Service enabled')),
E('td', { 'class': 'td left', 'id': 'weechat-service-enabled' }, renderEnabledState(isEnabled))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Autostart at boot')),
E('td', { 'class': 'td left' }, [ autostartBadge, autostartBtn ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Service control')),
E('td', { 'class': 'td left' }, [ btnStart, btnRestart, btnStop ])
])
])
]),
/* Card 2: Overview */
E('div', { 'class': 'weechat-panel' }, [
E('h3', {}, _('Overview')),
E('table', {}, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'style': 'width: 30%;' }, _('Relay server')),
E('td', { 'class': 'td left' }, relayStatusNode)
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('IRC servers')),
E('td', { 'class': 'td left' }, ircStatusNode)
])
])
])
]),
/* Configured IRC Servers Table */
E('div', { 'class': 'weechat-servers-panel' }, [
E('h3', {}, _('Configured IRC Servers')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr cbi-section-table-titles' }, [
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 10%; white-space: nowrap;' }, _('Status')),
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 12%; white-space: nowrap;' }, _('Server')),
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 20%; white-space: nowrap;' }, _('Hostname')),
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 7%; white-space: nowrap;' }, _('Port')),
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 7%; white-space: nowrap;' }, _('TLS')),
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 13%; white-space: nowrap;' }, _('Nicknames')),
E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 26%;' }, _('Channels')),
E('th', { 'class': 'th cbi-section-table-cell cbi-section-actions', 'style': 'width: 5%; text-align: right;' }, '')
])
].concat(serverRows))
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,65 @@
.weechat-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
margin-bottom: 1.25rem;
}
@media (max-width: 840px) {
.weechat-status-grid {
grid-template-columns: 1fr;
}
}
.weechat-panel {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 6px;
padding: 16px 20px;
background: rgba(128, 128, 128, 0.02);
display: flex;
flex-direction: column;
}
.weechat-panel h3 {
margin-top: 0;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
font-size: 1.15rem;
font-weight: 600;
}
.weechat-panel table {
width: 100%;
margin-bottom: 0;
border-collapse: collapse;
flex: 1;
}
.weechat-panel td {
padding: 10px 4px;
vertical-align: middle;
border: none;
}
.weechat-servers-panel {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 6px;
padding: 16px 20px;
background: rgba(128, 128, 128, 0.02);
margin-top: 0.5rem;
}
.weechat-servers-panel h3 {
margin-top: 0;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
font-size: 1.15rem;
font-weight: 600;
}
.weechat-btn-control {
min-width: 72px;
margin-right: 6px;
}