plugin gallery

This commit is contained in:
antelle 2017-05-13 22:36:07 +02:00
parent 6d8afb678e
commit 4664979e8d
9 changed files with 198 additions and 8 deletions

View File

@ -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;

View File

@ -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",

View File

@ -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;

View File

@ -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);

View File

@ -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
});

View File

@ -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;

View File

@ -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);
}
});

View File

@ -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;
}
}
}
}

View File

@ -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}}>