mirror of
https://github.com/openwrt/luci.git
synced 2026-02-04 12:06:01 +08:00
Remove manual UI setup and implement JSONMap. Signed-off-by: Paul Donald <newtwen+github@gmail.com>
320 lines
8.6 KiB
JavaScript
320 lines
8.6 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
'require fs';
|
|
'require form';
|
|
'require ui';
|
|
'require view';
|
|
|
|
const APK_DIR = '/etc/apk/keys/';
|
|
const OPKG_DIR = '/etc/opkg/keys/';
|
|
const isReadonlyView = !L.hasViewPermission() || null;
|
|
|
|
let KEYDIR = null;
|
|
let KEYEXT = null;
|
|
|
|
/* This safeList is not bullet-proof, but should prevent users
|
|
accidentally deleting official repo keys */
|
|
const safeList = [
|
|
'd310c6f2833e97f7', // 24.10 release usign key
|
|
'openwrt-snapshots.pem', // main snapshots EC pub key
|
|
];
|
|
|
|
function isFileInSafeList(file){
|
|
for (let name of safeList) {
|
|
if (file === name)
|
|
return true;
|
|
if (file.toLocaleLowerCase().replace(/^openwrt-[0-9]+\.[0-9]+/i, '') !== file)
|
|
return true;
|
|
if (file.toLocaleLowerCase().replace(/^openwrt-snapshots/i, '') !== file)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeKey(s) {
|
|
return s?.replace(/\s+/g, ' ')?.trim();
|
|
}
|
|
|
|
function determineKeyEnv() {
|
|
return fs.stat(APK_DIR).then(() => {
|
|
KEYDIR = APK_DIR;
|
|
KEYEXT = '.pem'; // not strictly necessary - apk allows any extension
|
|
}).catch(() => {
|
|
KEYDIR = OPKG_DIR;
|
|
KEYEXT = null; // opkg requires key filenames without an extension
|
|
});
|
|
}
|
|
|
|
function listKeyFiles() {
|
|
return fs.list(KEYDIR).then(entries =>
|
|
Promise.all(entries.map(entry =>
|
|
fs.read(KEYDIR + entry.name).then(content => ({
|
|
filename: entry.name,
|
|
key: content
|
|
}))
|
|
))
|
|
);
|
|
}
|
|
|
|
function safeText(str) {
|
|
return String(str).replace(/[&<>"']/g, s => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[s]));
|
|
}
|
|
|
|
function saveKeyFile(keyContent, file, fileContent) {
|
|
const ts = Date.now();
|
|
// Note: opkg can only verify against a key with filename that matches its key hash
|
|
// generate a file name in case key content was pasted
|
|
const filename = file ? file?.name?.split('.')?.[0] + (KEYEXT || '') : null;
|
|
const noname = 'key_' + ts + (KEYEXT || '');
|
|
return fs.write(KEYDIR + (filename ?? noname), fileContent ?? keyContent, 384 /* 0600 */);
|
|
}
|
|
|
|
function removeKey(ev, key) {
|
|
L.showModal(_('Delete key'), [
|
|
E('div', _('Really delete the following software repository public key?')),
|
|
E('pre', [ key.filename ]),
|
|
E('div', { class: 'right' }, [
|
|
E('div', { class: 'btn', click: L.hideModal }, _('Cancel')),
|
|
' ',
|
|
E('div', {
|
|
class: 'btn danger',
|
|
click: function() {
|
|
fs.remove(KEYDIR + key.filename)
|
|
.then(() => window.location.reload())
|
|
.catch(e => ui.addNotification(null, E('p', e.message)))
|
|
.finally(() => ui.hideModal());
|
|
}
|
|
}, _('Delete key'))
|
|
])
|
|
]);
|
|
}
|
|
|
|
function isPemFormat(content) {
|
|
return new RegExp('-BEGIN ([A-Z ]+)?PUBLIC KEY-').test(content);
|
|
}
|
|
|
|
function keyEnvironmentCheck(key) {
|
|
const isPem = isPemFormat(key);
|
|
|
|
// Reject PEM in OPKG; reject non-PEM in APK
|
|
if (KEYDIR === OPKG_DIR && isPem)
|
|
return _('This key appears to be in PEM format, which is not supported in an opkg environment.');
|
|
if (KEYDIR === APK_DIR && !isPem)
|
|
return _('This key does not appear to be in PEM format, which is required in an apk environment.');
|
|
|
|
return null;
|
|
}
|
|
|
|
function addKey(ev, file, fileContent) {
|
|
const input = document.getElementById('key-input');
|
|
const key = (fileContent ?? input?.value?.trim());
|
|
|
|
if (!key || !key.length)
|
|
return;
|
|
|
|
// Handle remote URL paste
|
|
if (/^https?:\/\/\S+$/i.test(key) && !fileContent) {
|
|
ui.addTimeLimitedNotification(_('Fetching key from URL…'), [], 5000, 'info');
|
|
|
|
L.Request.request(key, { method: 'GET' }).then(res => {
|
|
if (res.status !== 200) {
|
|
ui.addTimeLimitedNotification(_('Failed to fetch key'), [
|
|
E('p', _('HTTP error %d').format(res.status)),
|
|
], 7000, 'warning');
|
|
return;
|
|
}
|
|
|
|
const fetched = res.responseText?.trim();
|
|
if (!fetched || fetched.length > 8192) {
|
|
ui.addTimeLimitedNotification(_('Key file too large'), [
|
|
E('p', _('Fetched content seems too long. Maximum 8192 bytes.')),
|
|
], 7000, 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!fetched || fetched.length < 32) {
|
|
ui.addTimeLimitedNotification(_('Invalid or empty key file'), [
|
|
E('p', _('Fetched content seems empty or too short.')),
|
|
], 7000, 'warning');
|
|
return;
|
|
}
|
|
|
|
const filename = res?.url?.split('/').pop().split('?')[0].split('#')[0];
|
|
|
|
// Remove extension if any (we'll re-add based on environment)
|
|
const file = {name: filename.replace(/\.[^.]+$/, '') };
|
|
|
|
addKey(ev, file, fetched);
|
|
}).catch(err => {
|
|
ui.addTimeLimitedNotification(_('Failed to fetch key'), [
|
|
E('p', err.message),
|
|
], 7000, 'warning');
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// From here on, key content (either pasted, fetched, or dropped)
|
|
const formatError = keyEnvironmentCheck(key);
|
|
if (formatError) {
|
|
ui.addTimeLimitedNotification(_('Invalid key format'), [
|
|
E('p', formatError)
|
|
], 7000, 'warning');
|
|
return;
|
|
}
|
|
|
|
// Prevent duplicates
|
|
listKeyFiles().then(existingKeys => {
|
|
if (existingKeys.some(k => normalizeKey(k.key) === normalizeKey(key))) {
|
|
ui.addTimeLimitedNotification(_('Add key'), [
|
|
E('div', _('The given software repository public key is already present.')),
|
|
], 7000, 'notice');
|
|
return;
|
|
}
|
|
|
|
// Save and refresh the UI
|
|
input.value = '';
|
|
saveKeyFile(key, file, fileContent)
|
|
.then(() => window.location.reload())
|
|
.catch(e => ui.addNotification(null, E('p', e.message)));
|
|
});
|
|
}
|
|
|
|
function dragKey(ev) {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
ev.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
|
|
function dropKey(ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
|
|
const input = document.getElementById('key-input');
|
|
|
|
if (!input)
|
|
return;
|
|
|
|
for (const file of ev.dataTransfer.files) {
|
|
const reader = new FileReader();
|
|
reader.onload = rev => {
|
|
input.value = rev.target.result;
|
|
addKey(ev, file, rev.target.result);
|
|
input.value = '';
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
}
|
|
|
|
function handleWindowDragDropIgnore(ev) {
|
|
ev.preventDefault();
|
|
}
|
|
|
|
return view.extend({
|
|
load() {
|
|
return Promise.all([
|
|
determineKeyEnv().then(listKeyFiles),
|
|
]);
|
|
},
|
|
|
|
render([keys]) {
|
|
|
|
const m = new form.JSONMap({
|
|
keys: keys,
|
|
fup: {},
|
|
},
|
|
_('Repository Public Keys'), _(
|
|
_('Each software repository public key (from official or third party repositories) allows packages in lists signed by it to be installed by the package manager.') + '<br/>' +
|
|
_('Each key is stored as a file in %s.').format(`<code>${KEYDIR}</code>`)
|
|
));
|
|
m.submit = false;
|
|
m.reset = false;
|
|
m.readonly = isReadonlyView;
|
|
|
|
let s, o;
|
|
|
|
s = m.section(form.TableSection, 'keys');
|
|
s.anonymous = true;
|
|
s.nodescriptions = true;
|
|
|
|
o = s.option(form.DummyValue, 'filename', _('Name'));
|
|
o.width = '20%';
|
|
o = s.option(form.TextValue, 'key', _('Key'));
|
|
o.readonly = true;
|
|
o.monospace = true;
|
|
o.cols = 85;
|
|
o.rows = 5;
|
|
|
|
s.renderRowActions = function (section_id) {
|
|
const key = this.map.data.get(this.map.config, section_id);
|
|
const isReservedKey = isFileInSafeList(key.filename);
|
|
|
|
const btns = [
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-negative remove',
|
|
'click': ui.createHandlerFn(this, this.handleRemove, key),
|
|
'disabled': isReservedKey ? true : null,
|
|
}, [_('Delete')]),
|
|
];
|
|
|
|
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
|
|
};
|
|
|
|
s.handleRemove = function(key, ev) {
|
|
if (isFileInSafeList(key.filename)) {
|
|
ui.addTimeLimitedNotification(null, E('p', _('This key is protected and cannot be deleted.')), 3000, 'warning');
|
|
return;
|
|
}
|
|
|
|
return removeKey(ev, key)
|
|
};
|
|
|
|
s = m.section(form.NamedSection, 'fup');
|
|
|
|
o = s.option(form.DummyValue, '_newkey');
|
|
o.cfgvalue = function(section_id) {
|
|
|
|
const addInput = E('textarea', {
|
|
id: 'key-input',
|
|
'aria-label': _('Paste or drag repository public key'),
|
|
class: 'cbi-input-text',
|
|
type: 'text',
|
|
style: 'width: 100%; min-height: 120px;',
|
|
placeholder: _('Paste content of a file, or a URL to a key file, or drag and drop here to upload a software repository public key…'),
|
|
keydown: function(ev) { if (ev.keyCode === 13 && (ev.ctrlKey || ev.metaKey)) addKey(ev); },
|
|
disabled: isReadonlyView
|
|
});
|
|
|
|
addInput.addEventListener('dragover', handleWindowDragDropIgnore);
|
|
addInput.addEventListener('drop', handleWindowDragDropIgnore);
|
|
|
|
const addBtn = E('button', {
|
|
class: 'cbi-button',
|
|
click: ui.createHandlerFn(this, addKey),
|
|
disabled: isReadonlyView
|
|
}, _('Add key'));
|
|
|
|
return E('div', {
|
|
class: 'cbi-section-node',
|
|
dragover: isReadonlyView ? null : dragKey,
|
|
drop: isReadonlyView ? null : dropKey
|
|
}, [
|
|
E('div', { class: 'cbi-section-descr' }, _('Add new repository public key by pasting its content, a file, or a URL.')),
|
|
E('div', {
|
|
'style': 'height: 20px',
|
|
}, [' ']),
|
|
addInput,
|
|
E('div', { class: 'right' }, [ addBtn ])
|
|
]);
|
|
};
|
|
|
|
return m.render();
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null
|
|
});
|