mirror of https://github.com/keeweb/keeweb.git
plugin gallery
This commit is contained in:
parent
6d8afb678e
commit
4664979e8d
|
@ -11,7 +11,8 @@ const Links = {
|
|||
Manifest: 'https://app.keeweb.info/manifest.appcache',
|
||||
AutoType: 'https://github.com/keeweb/keeweb/wiki/Auto-Type',
|
||||
Translation: 'https://keeweb.oneskyapp.com/',
|
||||
Donation: 'https://www.paypal.me/dvitkovsky'
|
||||
Donation: 'https://www.paypal.me/dvitkovsky',
|
||||
Plugins: 'https://plugins.keeweb.info'
|
||||
};
|
||||
|
||||
module.exports = Links;
|
||||
|
|
|
@ -469,6 +469,11 @@
|
|||
"setPlLoadTime": "took {} to load",
|
||||
"setPlLastUpdate": "Last check for updates",
|
||||
"setPlLoadError": "error loading plugin",
|
||||
"setPlGalleryLoading": "Loading plugins, please wait a bit",
|
||||
"setPlGalleryLoadError": "Error loading plugins",
|
||||
"setPlInstallUrlTitle": "Add plugin from URL",
|
||||
"setPlInstallUrlDesc": "If the plugin is not in the gallery, you can install it manually from URL",
|
||||
"setPlOfficial": "Official KeeWeb plugin",
|
||||
|
||||
"setAboutTitle": "About",
|
||||
"setAboutBuilt": "This app is built with these awesome tools",
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
const Backbone = require('backbone');
|
||||
const kdbxweb = require('kdbxweb');
|
||||
const Links = require('../const/links');
|
||||
const SignatureVerifier = require('../util/signature-verifier');
|
||||
const Logger = require('../util/logger');
|
||||
|
||||
const PluginGallery = {
|
||||
logger: new Logger('plugin-gallery'),
|
||||
|
||||
gallery: null,
|
||||
loading: false,
|
||||
loadError: null,
|
||||
|
||||
loadPlugins() {
|
||||
if (this.gallery) {
|
||||
return Promise.resolve(this.gallery);
|
||||
}
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
return new Promise(resolve => {
|
||||
const ts = this.logger.ts();
|
||||
this.logger.debug('Loading plugins...');
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', Links.Plugins + '/plugins.json?_=' + new Date().getTime());
|
||||
xhr.responseType = 'text';
|
||||
xhr.send();
|
||||
xhr.addEventListener('load', () => {
|
||||
const data = xhr.response;
|
||||
const gallery = JSON.parse(data);
|
||||
const signature = gallery.signature;
|
||||
const dataToVerify = data.replace(signature, '');
|
||||
SignatureVerifier.verify(
|
||||
kdbxweb.ByteUtils.stringToBytes(dataToVerify),
|
||||
kdbxweb.ByteUtils.base64ToBytes(signature)
|
||||
).then(isValid => {
|
||||
if (!isValid) {
|
||||
this.logger.error('JSON signature invalid');
|
||||
this.galleryLoadError = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Loaded ${gallery.plugins.length} plugins`, this.logger.ts(ts));
|
||||
resolve(gallery);
|
||||
}).catch(e => {
|
||||
this.logger.error('Error verifying plugins signature', e);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
xhr.addEventListener('error', () => {
|
||||
this.logger.error('Network error loading plugins');
|
||||
resolve();
|
||||
});
|
||||
}).then(gallery => {
|
||||
this.loading = false;
|
||||
this.loadError = !gallery;
|
||||
if (gallery) {
|
||||
this.gallery = gallery;
|
||||
}
|
||||
Backbone.trigger('plugin-gallery-load-complete');
|
||||
return gallery;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = PluginGallery;
|
|
@ -28,10 +28,10 @@ const PluginManager = Backbone.Model.extend({
|
|||
});
|
||||
},
|
||||
|
||||
install(url) {
|
||||
install(url, expectedManifest) {
|
||||
const lastInstall = { url, dt: new Date() };
|
||||
this.set({ installing: url, lastInstall: lastInstall });
|
||||
return Plugin.loadFromUrl(url).then(plugin => {
|
||||
return Plugin.loadFromUrl(url, expectedManifest).then(plugin => {
|
||||
return this.uninstall(plugin.id).then(() => {
|
||||
return plugin.install(true).then(() => {
|
||||
this.get('plugins').push(plugin);
|
||||
|
|
|
@ -477,7 +477,7 @@ const Plugin = Backbone.Model.extend(_.extend({}, PluginStatus, {
|
|||
|
||||
_.extend(Plugin, PluginStatus);
|
||||
|
||||
Plugin.loadFromUrl = function(url) {
|
||||
Plugin.loadFromUrl = function(url, expectedManifest) {
|
||||
if (url[url.length - 1] !== '/') {
|
||||
url += '/';
|
||||
}
|
||||
|
@ -496,6 +496,14 @@ Plugin.loadFromUrl = function(url) {
|
|||
throw 'Failed to parse manifest';
|
||||
}
|
||||
commonLogger.debug('Loaded manifest', manifest);
|
||||
if (expectedManifest) {
|
||||
if (expectedManifest.name !== manifest.name) {
|
||||
throw 'Bad plugin name';
|
||||
}
|
||||
if (expectedManifest.privateKey !== manifest.privateKey) {
|
||||
throw 'Bad plugin private key';
|
||||
}
|
||||
}
|
||||
return new Plugin({
|
||||
manifest, url
|
||||
});
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
const Logger = require('./logger');
|
||||
const publicKey = require('raw-loader!../../resources/public-key.pem');
|
||||
const kdbxweb = require('kdbxweb');
|
||||
|
||||
const SignatureVerifier = {
|
||||
logger: new Logger('signature-verifier'),
|
||||
|
||||
verify(data, signature) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
let pk = publicKey.match(/-+BEGIN PUBLIC KEY-+([\s\S]+?)-+END PUBLIC KEY-+/)[1];
|
||||
pk = pk.replace(/\s+/g, '');
|
||||
pk = kdbxweb.ByteUtils.base64ToBytes(pk);
|
||||
crypto.subtle.importKey('spki', pk,
|
||||
{name: 'RSASSA-PKCS1-v1_5', hash: {name: 'SHA-256'}},
|
||||
false, ['verify']
|
||||
).then(cryptoKey => {
|
||||
crypto.subtle.verify({name: 'RSASSA-PKCS1-v1_5'}, cryptoKey,
|
||||
kdbxweb.ByteUtils.arrayToBuffer(signature),
|
||||
kdbxweb.ByteUtils.arrayToBuffer(data)
|
||||
).then(isValid => {
|
||||
resolve(isValid);
|
||||
}).catch(e => {
|
||||
this.logger.error('Verify error', e);
|
||||
reject();
|
||||
});
|
||||
}).catch(e => {
|
||||
this.logger.error('ImportKey error', e);
|
||||
reject();
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Signature verification error', e);
|
||||
reject();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = SignatureVerifier;
|
|
@ -1,6 +1,7 @@
|
|||
const Backbone = require('backbone');
|
||||
const Locale = require('../../util/locale');
|
||||
const PluginManager = require('../../plugins/plugin-manager');
|
||||
const PluginGallery = require('../../plugins/plugin-gallery');
|
||||
const AppSettingsModel = require('../../models/app-settings-model');
|
||||
const Comparators = require('../../util/comparators');
|
||||
const Format = require('../../util/format');
|
||||
|
@ -15,11 +16,14 @@ const SettingsPluginsView = Backbone.View.extend({
|
|||
'click .settings_plugins-enable-btn': 'enableClick',
|
||||
'click .settings_plugins-update-btn': 'updateClick',
|
||||
'click .settings_plugins-use-locale-btn': 'useLocaleClick',
|
||||
'click .settings_plugins-use-theme-btn': 'useThemeClick'
|
||||
'click .settings_plugins-use-theme-btn': 'useThemeClick',
|
||||
'click .settings__plugins-gallery-plugin-install-btn': 'galleryInstallClick'
|
||||
},
|
||||
|
||||
initialize() {
|
||||
this.listenTo(PluginManager, 'change', this.render.bind(this));
|
||||
this.listenTo(Backbone, 'plugin-gallery-load-complete', this.render.bind(this));
|
||||
PluginGallery.loadPlugins();
|
||||
},
|
||||
|
||||
render() {
|
||||
|
@ -32,14 +36,24 @@ const SettingsPluginsView = Backbone.View.extend({
|
|||
installTime: Math.round(plugin.get('installTime')),
|
||||
updateError: plugin.get('updateError'),
|
||||
updateCheckDate: Format.dtStr(plugin.get('updateCheckDate')),
|
||||
installError: plugin.get('installError')
|
||||
installError: plugin.get('installError'),
|
||||
})).sort(Comparators.stringComparator('id', true)),
|
||||
lastInstallUrl: PluginManager.get('installing') || (lastInstall.error ? lastInstall.url : ''),
|
||||
lastInstallError: lastInstall.error
|
||||
lastInstallError: lastInstall.error,
|
||||
galleryLoading: PluginGallery.loading,
|
||||
galleryLoadError: PluginGallery.loadError,
|
||||
gallery: this.getGallery()
|
||||
});
|
||||
return this;
|
||||
},
|
||||
|
||||
getGallery() {
|
||||
if (!PluginGallery.gallery) {
|
||||
return null;
|
||||
}
|
||||
return PluginGallery.gallery;
|
||||
},
|
||||
|
||||
installClick() {
|
||||
const installBtn = this.$el.find('.settings_plugins-install-btn');
|
||||
const urlTextBox = this.$el.find('#settings__plugins-install-url');
|
||||
|
@ -97,6 +111,12 @@ const SettingsPluginsView = Backbone.View.extend({
|
|||
useThemeClick(e) {
|
||||
const theme = $(e.target).data('theme');
|
||||
AppSettingsModel.instance.set('theme', theme);
|
||||
},
|
||||
|
||||
galleryInstallClick(e) {
|
||||
const pluginId = $(e.target).data('plugin');
|
||||
const plugin = PluginGallery.gallery.plugins.find(pl => pl.manifest.name === pluginId);
|
||||
PluginManager.install(plugin.url, plugin.manifest);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -190,5 +190,25 @@
|
|||
&-plugin-desc {
|
||||
margin-bottom: $base-padding-v;
|
||||
}
|
||||
&-gallery {
|
||||
margin-top: $base-spacing;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
&-gallery-plugin {
|
||||
position: relative;
|
||||
width: calc(50% - 40px);
|
||||
border-radius: $base-border-radius;
|
||||
border: light-border();
|
||||
padding: 0 $base-padding-h $base-padding-v;
|
||||
box-sizing: border-box;
|
||||
margin: 0 $base-padding-v $base-padding-h 0;
|
||||
vertical-align: top;
|
||||
&-install-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,9 +43,41 @@
|
|||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<h2>{{res 'setPlInstallTitle'}}</h2>
|
||||
<h2>
|
||||
{{#if galleryLoading}}{{res 'setPlGalleryLoading'}}...{{else}}{{res 'setPlInstallTitle'}}{{/if}}
|
||||
</h2>
|
||||
<div class="settings__plugins-install">
|
||||
<div>{{res 'setPlInstallDesc'}}</div>
|
||||
{{#if galleryLoadError}}<div class="error-color">{{res 'setPlGalleryLoadError'}}</div>{{/if}}
|
||||
{{#if gallery}}
|
||||
<div class="settings__plugins-gallery">
|
||||
{{#each gallery.plugins as |plugin|}}
|
||||
<div class="settings__plugins-gallery-plugin">
|
||||
<h3 class="settings__plugins-gallery-plugin-title">{{plugin.manifest.name}}</h3>
|
||||
<div class="settings__plugins-gallery-plugin-desc">{{plugin.manifest.description}}</div>
|
||||
<ul class="settings__plugins-plugin-files">
|
||||
{{#if plugin.manifest.resources.js}}<li class="settings__plugins-plugin-file"><i class="fa fa-code"></i> {{res 'setPlJs'}}</li>{{/if}}
|
||||
{{#if plugin.manifest.resources.css}}<li class="settings__plugins-plugin-file"><i class="fa fa-paint-brush"></i> {{res 'setPlCss'}}</li>{{/if}}
|
||||
{{#if plugin.manifest.resources.loc}}<li class="settings__plugins-plugin-file"><i class="fa fa-language"></i> {{res 'setPlLoc'}}: {{plugin.manifest.locale.title}}</li>{{/if}}
|
||||
</ul>
|
||||
<div class="settings__plugins-gallery-plugin-author muted-color">
|
||||
{{#if plugin.official}}
|
||||
<i class="fa fa-check"></i> {{res 'setPlOfficial'}}
|
||||
{{else}}
|
||||
<i class="fa fa-at"></i> <a href="{{plugin.manifest.author.url}}" target="_blank">{{plugin.manifest.author.name}}</a> ({{plugin.manifest.author.email}})
|
||||
{{/if}}
|
||||
</div>
|
||||
<button class="settings__plugins-gallery-plugin-install-btn"
|
||||
data-plugin="{{plugin.manifest.name}}"
|
||||
>{{res 'setPlInstallBtn'}}</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<h2>{{res 'setPlInstallUrlTitle'}}</h2>
|
||||
<div class="settings__plugins-install-url">
|
||||
<div>{{res 'setPlInstallUrlDesc'}}</div>
|
||||
<label for="settings__plugins-install-url">{{res 'setPlInstallLabel'}}</label>
|
||||
<input type="text" class="settings__input input-base" id="settings__plugins-install-url" value="{{lastInstallUrl}}" />
|
||||
<button class="settings_plugins-install-btn" {{#if installing}}disabled{{/if}}>
|
||||
|
|
Loading…
Reference in New Issue