From 20cc765fb5c7b1bc149e4ae4f6c0a74be846a80a Mon Sep 17 00:00:00 2001 From: antelle Date: Sat, 18 Feb 2017 23:46:59 +0100 Subject: [PATCH] plugins first implementation --- .editorconfig | 3 + app/scripts/comp/export-api.js | 16 - app/scripts/locales/base.json | 7 + app/scripts/models/menu/menu-model.js | 2 + app/scripts/plugins/plugin-api.js | 22 ++ app/scripts/plugins/plugin-manager.js | 28 ++ app/scripts/plugins/plugin.js | 291 ++++++++++++++++++ .../views/settings/settings-plugins-view.js | 50 +++ app/styles/areas/_settings.scss | 5 + app/templates/settings/settings-plugins.hbs | 12 + plugins/example/.gitignore | 3 + plugins/example/manifest.json | 18 ++ plugins/example/plugin.css | 11 + plugins/example/plugin.js | 18 ++ plugins/kw-plugin-control.js | 283 +++++++++++++++++ 15 files changed, 753 insertions(+), 16 deletions(-) create mode 100644 app/scripts/plugins/plugin-api.js create mode 100644 app/scripts/plugins/plugin-manager.js create mode 100644 app/scripts/plugins/plugin.js create mode 100644 app/scripts/views/settings/settings-plugins-view.js create mode 100644 app/templates/settings/settings-plugins.hbs create mode 100644 plugins/example/.gitignore create mode 100644 plugins/example/manifest.json create mode 100644 plugins/example/plugin.css create mode 100644 plugins/example/plugin.js create mode 100644 plugins/kw-plugin-control.js diff --git a/.editorconfig b/.editorconfig index 020c2623..33a2c640 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,5 +24,8 @@ indent_size = 4 [*.scss] indent_size = 2 +[*.css] +indent_size = 2 + [*.nsh] indent_size = 2 diff --git a/app/scripts/comp/export-api.js b/app/scripts/comp/export-api.js index b4775996..fe7d969d 100644 --- a/app/scripts/comp/export-api.js +++ b/app/scripts/comp/export-api.js @@ -2,26 +2,10 @@ const AppSettingsModel = require('../models/app-settings-model'); -const Libs = { - backbone: require('backbone'), - _: require('underscore'), - underscore: require('underscore'), - $: require('jquery'), - jquery: require('jquery'), - kdbxweb: require('kdbxweb'), - hbs: require('hbs'), - pikaday: require('pikaday'), - filesaver: require('filesaver'), - qrcode: require('qrcode') -}; - const ExportApi = { settings: { get: function(key) { return key ? AppSettingsModel.instance.get(key) : AppSettingsModel.instance.toJSON(); }, set: function(key, value) { AppSettingsModel.instance.set(key, value); } - }, - require: function(module) { - return Libs[module] || require('../' + module); } }; diff --git a/app/scripts/locales/base.json b/app/scripts/locales/base.json index 5d74861b..5a0f594b 100644 --- a/app/scripts/locales/base.json +++ b/app/scripts/locales/base.json @@ -24,6 +24,7 @@ "shortcuts": "Shortcuts", "help": "Help", "settings": "Settings", + "plugins": "Plugins", "history": "history", "cache": "cache", @@ -444,6 +445,12 @@ "setShAutoTypeGlobal": "auto-type (when app is in background)", "setShLock": "lock database", + "setPlInstallTitle": "Install new plugins", + "setPlInstallDesc": "KeeWeb plugins add features, themes and locales to KeeWeb. Plugins run with the same privileges as KeeWeb, they can access and manage all your passwords. Never install plugins you don't trust.", + "setPlInstallLabel": "Plugin URL", + "setPlInstallBtn": "Install", + "setPlInstallBtnProgress": "Installing", + "setAboutTitle": "About", "setAboutBuilt": "This app is built with these awesome tools", "setAboutLic": "License", diff --git a/app/scripts/models/menu/menu-model.js b/app/scripts/models/menu/menu-model.js index a5c8fb5d..a71b4a51 100644 --- a/app/scripts/models/menu/menu-model.js +++ b/app/scripts/models/menu/menu-model.js @@ -45,6 +45,7 @@ const MenuModel = Backbone.Model.extend({ this.generalSection = new MenuSectionModel([{ locTitle: 'menuSetGeneral', icon: 'cog', page: 'general', active: true }]); this.shortcutsSection = new MenuSectionModel([{ locTitle: 'shortcuts', icon: 'keyboard-o', page: 'shortcuts' }]); + this.pluginsSection = new MenuSectionModel([{ locTitle: 'plugins', icon: 'puzzle-piece', page: 'plugins' }]); this.aboutSection = new MenuSectionModel([{ locTitle: 'menuSetAbout', icon: 'info', page: 'about' }]); this.helpSection = new MenuSectionModel([{ locTitle: 'help', icon: 'question', page: 'help' }]); this.filesSection = new MenuSectionModel(); @@ -52,6 +53,7 @@ const MenuModel = Backbone.Model.extend({ this.menus.settings = new MenuSectionCollection([ this.generalSection, this.shortcutsSection, + this.pluginsSection, this.aboutSection, this.helpSection, this.filesSection diff --git a/app/scripts/plugins/plugin-api.js b/app/scripts/plugins/plugin-api.js new file mode 100644 index 00000000..a41d13b0 --- /dev/null +++ b/app/scripts/plugins/plugin-api.js @@ -0,0 +1,22 @@ +'use strict'; + +const Libs = { + backbone: require('backbone'), + _: require('underscore'), + underscore: require('underscore'), + $: require('jquery'), + jquery: require('jquery'), + kdbxweb: require('kdbxweb'), + hbs: require('hbs'), + pikaday: require('pikaday'), + filesaver: require('filesaver'), + qrcode: require('qrcode') +}; + +const PluginApi = { + require(module) { + return Libs[module] || require('../' + module); + } +}; + +module.exports = PluginApi; diff --git a/app/scripts/plugins/plugin-manager.js b/app/scripts/plugins/plugin-manager.js new file mode 100644 index 00000000..6d8488e1 --- /dev/null +++ b/app/scripts/plugins/plugin-manager.js @@ -0,0 +1,28 @@ +'use strict'; + +// const Logger = require('../util/logger'); +const Plugin = require('./plugin'); + +// const logger = new Logger('plugin-manager'); + +const PluginManager = { + plugins: {}, + + install(url) { + return Plugin.load(url).then(plugin => { + const name = plugin.get('name'); + return Promise.resolve().then(() => { + if (this.plugins[name]) { + return this.plugins[name].uninstall().then(() => { + delete this.plugins[name]; + }); + } + }).then(() => { + this.plugins[name] = plugin; + return plugin.install(); + }); + }); + } +}; + +module.exports = PluginManager; diff --git a/app/scripts/plugins/plugin.js b/app/scripts/plugins/plugin.js new file mode 100644 index 00000000..489223f2 --- /dev/null +++ b/app/scripts/plugins/plugin.js @@ -0,0 +1,291 @@ +'use strict'; + +const kdbxweb = require('kdbxweb'); +const Backbone = require('backbone'); +const PluginApi = require('./plugin-api') +const Logger = require('../util/logger'); + +const commonLogger = new Logger('plugin'); + +const Plugin = Backbone.Model.extend({ + defaults: { + name: '', + manifest: '', + url: '', + status: 'inactive' + }, + + resources: null, + + initialize(manifest, url) { + this.set('name', manifest.name); + this.set('manifest', manifest); + this.set('url', url); + this.logger = new Logger(`plugin:${manifest.name}`); + }, + + install() { + this.set('status', 'installing'); + return Promise.resolve().then(() => { + const error = this.validateManifest(); + if (error) { + this.logger.error('Manifest validation error', error); + this.set('status', 'invalid'); + throw 'Plugin validation error: ' + error; + } + return this.installWithManifest(); + }); + }, + + validateManifest() { + const manifest = this.get('manifest'); + if (!manifest.name) { + return 'No plugin name'; + } + if (!manifest.description) { + return 'No plugin description'; + } + if (!/^\d+\.\d+\.\d+$/.test(manifest.version || '')) { + return 'Invalid plugin version'; + } + if (manifest.manifestVersion !== '0.1.0') { + return 'Invalid manifest version ' + manifest.manifestVersion; + } + if (!manifest.author || !manifest.author.email || !manifest.author.name || !manifest.author.url) { + return 'Invalid plugin author'; + } + if (!manifest.url) { + return 'No plugin url'; + } + if (!manifest.publicKey) { + return 'No plugin public key'; + } + if (!manifest.resources || !Object.keys(manifest.resources).length) { + return 'No plugin resources'; + } + if (manifest.resources.loc && + (!manifest.locale || !manifest.locale.title || !/^[a-z]{2}(-[A-Z]{2})?$/.test(manifest.locale.name))) { + return 'Bad plugin locale'; + } + }, + + installWithManifest() { + const manifest = this.get('manifest'); + const url = this.get('url'); + this.logger.info('Loading plugin with resources', Object.keys(manifest.resources).join(', ')); + this.resources = {}; + const ts = this.logger.ts(); + const results = []; + if (manifest.resources.css) { + results.push(this.loadResource('css', url + 'plugin.css')); + } + if (manifest.resources.js) { + results.push(this.loadResource('js', url + 'plugin.js')); + } + if (manifest.resources.loc) { + results.push(this.loadResource('js', url + manifest.locale.name + '.json')); + } + return Promise.all(results) + .catch(() => { throw 'Error loading plugin resources'; }) + .then(() => this.installWithResources()) + .then(() => { + this.logger.info('Install complete', this.logger.ts(ts)); + }); + }, + + loadResource(type, url) { + let ts = this.logger.ts(); + const manifest = this.get('manifest'); + return httpGet(url, true).then(data => { + this.logger.debug('Resource data loaded', type, this.logger.ts(ts)); + ts = this.logger.ts(); + const key = kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(manifest.publicKey)); + const signature = kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(manifest.resources[type])); + const algo = { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }; + return kdbxweb.CryptoEngine.subtle.importKey('spki', key, algo, false, ['verify']) + .then(subtleKey => kdbxweb.CryptoEngine.subtle.verify(algo, subtleKey, signature, data)) + .catch(e => { + this.logger.error('Error validating resource signature', type, e); + throw e; + }) + .then(valid => { + if (valid) { + this.logger.debug('Resource signature valid', type, this.logger.ts(ts)); + this.resources[type] = data; + } else { + this.logger.error('Resource signature invalid', type); + throw `Signature invalid: ${type}`; + } + }); + }); + }, + + installWithResources() { + this.logger.info('Installing loaded plugin'); + const manifest = this.get('manifest'); + const promises = []; + if (this.resources.css) { + promises.push(this.applyCss(manifest.name, this.resources.css)); + } + if (this.resources.js) { + promises.push(this.applyJs(manifest.name, this.resources.js)); + } + if (this.resources.loc) { + promises.push(this.applyLoc(manifest.locale, this.resources.loc)); + } + this.set('status', 'active'); + return Promise.all(promises) + .catch(e => { + this.logger.info('Install error', e); + this.uninstall(); + throw e; + }); + }, + + applyCss(name, data) { + return Promise.resolve().then(() => { + const text = kdbxweb.ByteUtils.bytesToString(data); + this.createElementInHead('style', 'plugin-css-' + name, 'text/css', text); + this.logger.debug('Plugin style installed'); + }); + }, + + applyJs(name, data) { + return Promise.resolve().then(() => { + let text = kdbxweb.ByteUtils.bytesToString(data); + this.module = {exports: {}}; + const id = 'plugin-' + Date.now().toString() + Math.random().toString(); + global[id] = { + require: PluginApi.require, + module: this.module + }; + text = `(function(require, module){${text}})(window["${id}"].require,window["${id}"].module);`; + const ts = this.logger.ts(); + this.createElementInHead('script', 'plugin-js-' + name, 'text/javascript', text); + return new Promise((resolve, reject) => { + setTimeout(() => { + delete global[id]; + if (this.module.exports.uninstall) { + this.logger.debug('Plugin script installed', this.logger.ts(ts)); + resolve(); + } else { + reject('Plugin script installation failed'); + } + }, 0); + }); + }); + }, + + createElementInHead(tagName, id, type, text) { + let el = document.getElementById(id); + if (el) { + el.parentNode.removeChild(el); + } + el = document.createElement(tagName); + el.appendChild(document.createTextNode(text)); + el.setAttribute('id', id); + el.setAttribute('type', type); + document.head.appendChild(el); + }, + + removeElement(id) { + const el = document.getElementById(id); + if (el) { + el.parentNode.removeChild(el); + } + }, + + applyLoc(locale, data) { + return Promise.resolve().then(() => { + data = JSON.parse(data); + // TODO: apply locale + this.logger.debug('Plugin locale installed'); + }); + }, + + removeLoc(locale) { + // TODO: remove locale + }, + + uninstall() { + const manifest = this.get('manifest'); + this.logger.info('Uninstalling plugin with resources', Object.keys(manifest.resources).join(', ')); + const ts = this.logger.ts(); + return Promise.resolve().then(() => { + if (manifest.resources.css) { + this.removeElement('plugin-css-' + this.get('name')); + } + if (manifest.resources.js) { + try { + this.module.exports.uninstall(); + } catch (e) { + this.logger.error('Plugin uninstall method returned an error', e); + } + this.removeElement('plugin-js-' + this.get('name')); + } + if (manifest.resources.loc) { + this.removeLoc(this.get('manifest').locale); + } + this.logger.info('Uninstall complete', this.logger.ts(ts)); + }); + } +}); + +Plugin.load = function(url) { + if (url[url.length - 1] !== '/') { + url += '/'; + } + commonLogger.info('Installing plugin from url', url); + const manifestUrl = url + 'manifest.json'; + return httpGet(manifestUrl) + .catch(e => { + commonLogger.error('Error loading plugin manifest', e); + throw 'Error loading plugin manifest'; + }) + .then(manifest => { + try { + manifest = JSON.parse(manifest); + } catch (e) { + commonLogger.error('Failed to parse manifest', manifest); + throw 'Failed to parse manifest'; + } + commonLogger.debug('Loaded manifest', manifest); + return new Plugin(manifest, url); + }); +}; + +function httpGet(url, binary) { + commonLogger.debug('GET', url); + const ts = commonLogger.ts(); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + commonLogger.debug('GET OK', url, commonLogger.ts(ts)); + resolve(xhr.response); + } else { + commonLogger.debug('GET error', url, xhr.status); + reject(xhr.status ? `HTTP status ${xhr.status}` : 'network error'); + } + }); + xhr.addEventListener('error', () => { + commonLogger.debug('GET error', url, xhr.status); + reject(xhr.status ? `HTTP status ${xhr.status}` : 'network error'); + }); + xhr.addEventListener('abort', () => { + commonLogger.debug('GET aborted', url); + reject('Network request timeout'); + }); + xhr.addEventListener('timeout', () => { + commonLogger.debug('GET timeout', url); + reject('Network request timeout'); + }); + if (binary) { + xhr.responseType = binary ? 'arraybuffer' : 'text'; + } + xhr.open('GET', url); + xhr.send(); + }); +} + +module.exports = Plugin; diff --git a/app/scripts/views/settings/settings-plugins-view.js b/app/scripts/views/settings/settings-plugins-view.js new file mode 100644 index 00000000..73285c9f --- /dev/null +++ b/app/scripts/views/settings/settings-plugins-view.js @@ -0,0 +1,50 @@ +'use strict'; + +const Backbone = require('backbone'); +const Locale = require('../../util/locale'); +const PluginManager = require('../../plugins/plugin-manager'); + +const SettingsPluginsView = Backbone.View.extend({ + template: require('templates/settings/settings-plugins.hbs'), + + events: { + 'click .settings_plugins-install-btn': 'installClick' + }, + + render() { + this.renderTemplate({ + plugins: [] + }); + }, + + installClick() { + const installBtn = this.$el.find('.settings_plugins-install-btn'); + const urlTextBox = this.$el.find('#settings__plugins-install-url'); + const errorBox = this.$el.find('.settings__plugins-install-error'); + errorBox.html(''); + const url = urlTextBox.val().trim(); + if (!url) { + return; + } + urlTextBox.prop('disabled', true); + installBtn.text(Locale.setPlInstallBtnProgress + '...').prop('disabled', true); + PluginManager.install(url) + .then(() => { + this.installFinished(); + urlTextBox.val(''); + }) + .catch(e => { + this.installFinished(); + errorBox.text(e.toString()); + }); + }, + + installFinished() { + const installBtn = this.$el.find('.settings_plugins-install-btn'); + const urlTextBox = this.$el.find('#settings__plugins-install-url'); + urlTextBox.prop('disabled', false); + installBtn.text(Locale.setPlInstallBtn).prop('disabled', false); + } +}); + +module.exports = SettingsPluginsView; diff --git a/app/styles/areas/_settings.scss b/app/styles/areas/_settings.scss index 0a20c395..216daa63 100644 --- a/app/styles/areas/_settings.scss +++ b/app/styles/areas/_settings.scss @@ -146,4 +146,9 @@ &--error { color: $red; } } } + &__plugins { + &-install-error { + margin-top: $base-padding-h; + } + } } diff --git a/app/templates/settings/settings-plugins.hbs b/app/templates/settings/settings-plugins.hbs new file mode 100644 index 00000000..e82a4662 --- /dev/null +++ b/app/templates/settings/settings-plugins.hbs @@ -0,0 +1,12 @@ +
+

{{res 'plugins'}}

+

{{res 'setPlInstallTitle'}}

+
+
+
{{res 'setPlInstallDesc'}}
+ + + +
+
+
diff --git a/plugins/example/.gitignore b/plugins/example/.gitignore new file mode 100644 index 00000000..37fb1323 --- /dev/null +++ b/plugins/example/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +*.pem \ No newline at end of file diff --git a/plugins/example/manifest.json b/plugins/example/manifest.json new file mode 100644 index 00000000..9c8fc9ee --- /dev/null +++ b/plugins/example/manifest.json @@ -0,0 +1,18 @@ +{ + "version": "0.0.1", + "manifestVersion": "0.1.0", + "name": "example", + "description": "KeeWeb plugin example", + "author": { + "name": "antelle", + "email": "antelle.net@gmail.com", + "url": "http://antelle.net" + }, + "licence": "MIT", + "url": "https://keeweb.info", + "resources": { + "js": "X/xB9Y09Pkctu0Cwbl5i17+t9ngE2iD43kU/sv6rrXdw030N8mHWc667jE0OJQnDslOSleg5FCnnQ11/An3xZy/hgmDHZfgIu4nqJmkMwJVgxqiaBR28yl664PhJEY9te9XYqH213OSrM3HwWkqFx5qGn7lNZOPD5Wbq5lEflj2a+yTnQlx5AO1vRhdLBYl1OatsQ9smRhPb24AfTtWbNfOnoYCwgKtbvhReSu6R5Z7iei9TV6VeTFrCspXIQr9NWCHzbRunGTZjbtK0zYFK1+w4xAIxKUdfuMx00lrebq5y4Xn6Kv6NEFF9iouPUxH4oQ8lXeNd4FpsMdGOOQNlzQ==", + "css": "ny7Aj5PURIaD6Y6CLE6FMaIaj/z77XN6j7eUIA2N6agFLvwRBsZDVtM02oidivtVDA5nimc7/5iKzS2ppijFK67g1zMKfdZwZHOz5VdDGijBA8Zu4IybfSu4FWnbnWVjkCKLdaCS0kf6q/ivjgvovTqTMnuhzobN9jgTyV+TgYbof+dK1EiQCeQxf/ylJJTa3Sj9IMOdHNmu1y8Gxm1Zo58mlYA8ZQsImVjL6aNNn7Lc5yooexBxTzpHqBHe2KNlJoGorZG4VUOb1lIdnUlUke17pmbidueZt5tYJzJXFUPpd8yHxDDqAGxmaTEbohVCwslgDptEF56V1Xe9y0IzCw==" + }, + "publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1QB+yQofkqIHbHFVAtWhjEFjaxvNekyQx/aK7nEpZzqM8ReoVWbJGVA+7z7MhymwZanbL8uAUrSpNTp5eFWltDksxqHqmXT4UcFU+4reLjYfgwjIaA3c9Q+2JA1Iowqbv3NDcDKm6Ug+dROr04VDCfYE4WRYgGdTYHDbJs5svxUgQ25uc0KKUWAvhYbSKsw43AwmbPbKkHdfZHiS5ZST99HVEJWxn3Kd2zLY9Kk70nu9MzypMLDqxUqjKgdeRCIyZeAYzB75miH3B5vMKhFcdmA8+D6WU2N+6gzKsY5BfqF729uFKSTo4JUKQ5fMU0lKSDHG4qGrkgnURfAUuj9YMQIDAQAB" +} \ No newline at end of file diff --git a/plugins/example/plugin.css b/plugins/example/plugin.css new file mode 100644 index 00000000..bc3d54a1 --- /dev/null +++ b/plugins/example/plugin.css @@ -0,0 +1,11 @@ +i, svg { + animation: psychodelic-spin 1s linear 0s infinite; + transform-origin: center center; +} +@keyframes psychodelic-spin { + 0% { transform: rotate(0deg); } + 25% { transform: rotate(10deg); color: darkviolet; } + 50% { transform: rotate(0deg); } + 75% { transform: rotate(-10deg); color: hotpink; } + 100% { transform: rotate(0deg); } +} diff --git a/plugins/example/plugin.js b/plugins/example/plugin.js new file mode 100644 index 00000000..23a5f888 --- /dev/null +++ b/plugins/example/plugin.js @@ -0,0 +1,18 @@ +/** + * KeeWeb plugin: example + * @author antelle + * @license MIT + */ + +const DetailsView = require('views/details/details-view'); + +const detailsViewRender = DetailsView.prototype.render; +DetailsView.prototype.render = function() { + detailsViewRender.apply(this); + this.$el.find('.details__header:first').addClass('input-shake'); + return this; +}; + +module.exports.uninstall = function() { + DetailsView.prototype.render = detailsViewRender; +}; diff --git a/plugins/kw-plugin-control.js b/plugins/kw-plugin-control.js new file mode 100644 index 00000000..ca469067 --- /dev/null +++ b/plugins/kw-plugin-control.js @@ -0,0 +1,283 @@ +/** + * KeeWeb plugin creator + * (C) Antelle 2017, MIT license https://github.com/keeweb/keeweb + */ + +/* eslint-disable no-console */ + +const path = require('path'); +const fs = require('fs'); +const childProcess = require('child_process'); +const readline = require('readline'); +const crypto = require('crypto'); + +const args = process.argv.splice(2); + +const op = args.shift(); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +showBanner(); + +switch (op) { + case 'create': + createPlugin(); + break; + case 'sign': + signPlugin(); + break; + default: + showHelp(); +} + +function showBanner() { + console.log('KeeWeb plugin control'); +} + +function showHelp() { + rl.close(); + console.log('Usage:'); + console.log(' - node kw-plugin-control.js create'); + console.log(' - node kw-plugin-control.js sign '); +} + +function createPlugin() { + gatherPluginData(data => { + console.log('Creating plugin:', data.name); + createPluginFolder(data); + generateKeyPair(data); + createManifest(data); + createResources(data); + console.log('Plugin created, now it\'s time to make something awesome!'); + }); +} + +function gatherPluginData(callback) { + const data = { + version: '0.0.1', + manifestVersion: '0.1.0' + }; + + const questions = [{ + text: 'Plugin name', + callback: name => { + if (!name) { + return true; + } + if (fs.existsSync(name)) { + console.log(`Folder '${name}' already exists, please select another name`); + return true; + } + data.name = name; + next(); + } + }, { + text: 'Description', + callback: description => { + if (!description) { + return true; + } + data.description = description; + next(); + } + }, { + text: 'Author name', + callback: authorName => { + if (!authorName) { + return true; + } + data.author = { name: authorName }; + next(); + } + }, { + text: 'Author email', + callback: authorEmail => { + if (!authorEmail || authorEmail.indexOf('@') < 0 || authorEmail.indexOf('.') < 0) { + return true; + } + data.author.email = authorEmail; + next(); + } + }, { + text: 'Author url', + callback: authorUrl => { + if (!authorUrl || authorUrl.indexOf('http://') < 0 && authorUrl.indexOf('https://') < 0) { + return true; + } + data.author.url = authorUrl; + next(); + } + }, { + text: 'License (default: MIT)', + callback: licence => { + data.licence = licence || 'MIT'; + next(); + } + }, { + text: 'Plugin page URL', + callback: pluginUrl => { + if (!pluginUrl || pluginUrl.indexOf('http://') < 0 && pluginUrl.indexOf('https://') < 0) { + return true; + } + data.url = pluginUrl; + next(); + } + }, { + text: 'There are different types of KeeWeb plugins:\n ' + + '1: js only\n ' + + '2: js + css\n ' + + '3: css only\n ' + + '4: locale\n' + + 'Your plugin type is', + callback: res => { + const placeholder = ``; + switch (res) { + case '1': + data.resources = { js: placeholder }; + break; + case '2': + data.resources = { js: placeholder, css: placeholder }; + break; + case '3': + data.resources = { css: placeholder }; + break; + case '4': + data.resources = { loc: placeholder }; + break; + default: + console.error('Please enter number from 1 to 4'); + return true; + } + next(); + } + }, { + text: 'Locale code (format: xx or xx-XX)', + if: () => data.resources.loc, + callback: loc => { + if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(loc) || loc === 'en') { + console.error('Invalid locale'); + return true; + } + data.locale = { name: loc }; + next(); + } + }, { + text: 'Locale name (human-readable)', + if: () => data.resources.loc, + callback: loc => { + if (!loc || loc.toLowerCase() === 'english' || loc.toLowerCase() === 'default') { + console.error('Invalid locale'); + return true; + } + data.locale.title = loc; + next(); + } + }]; + + next(); + + function next() { + const question = questions.shift(); + if (!question) { + callback(data); + rl.close(); + } else { + if (question.if && !question.if()) { + return next(); + } + ask(question.text, question.callback); + } + } +} + +function ask(question, callback) { + rl.question(`${question}: `, answer => { + const res = callback(answer.trim()); + if (res) { + ask(res.question || question, callback); + } + }); +} + +function createPluginFolder(data) { + console.log('Creating plugin folder...'); + fs.mkdirSync(data.name); +} + +function generateKeyPair(data) { + console.log('Generating key pair...'); + const privateKeyPath = path.join(data.name, 'private_key.pem'); + const publicKeyPath = path.join(data.name, 'public_key.pem'); + childProcess.execSync(`openssl genpkey -algorithm RSA -out "${privateKeyPath}" -pkeyopt rsa_keygen_bits:2048`); + childProcess.execSync(`openssl rsa -pubout -in "${privateKeyPath}" -out "${publicKeyPath}"`); + data.publicKey = fs.readFileSync(path.join(data.name, 'public_key.pem'), 'utf8') + .match(/-+BEGIN PUBLIC KEY-+([\s\S]+?)-+END PUBLIC KEY-+/)[1] + .replace(/\n/g, ''); + fs.unlinkSync(publicKeyPath); +} + +function createManifest(data) { + console.log('Creating manifest...'); + fs.writeFileSync(path.join(data.name, 'manifest.json'), JSON.stringify(data, null, 2)); +} + +function createResources(data) { + console.log('Adding default files...'); + if (data.resources.js) { + fs.writeFileSync(path.join(data.name, 'plugin.js'), `/** + * KeeWeb plugin: ${data.name} + * @author ${data.author.name} + * @license ${data.licence} + */ + +/* global kw */ +`); + } + if (data.resources.css) { + fs.writeFileSync(path.join(data.name, 'plugin.css'), ''); + } + if (data.resources.loc) { + fs.writeFileSync(path.join(data.name, data.locale.name + '.json'), '{\n}'); + } + fs.writeFileSync(path.join(data.name, '.gitignore'), ['.DS_Store', '*.log', '*.pem'].join('\n')); +} + +function signPlugin() { + rl.close(); + const packageName = args.shift(); + if (!packageName) { + showHelp(); + return; + } + if (!fs.existsSync(packageName)) { + console.error('Package folder does not exist'); + return process.exit(1); + } + const manifest = JSON.parse(fs.readFileSync(path.join(packageName, 'manifest.json'))); + const privateKey = fs.readFileSync(path.join(packageName, 'private_key.pem'), 'binary'); + for (const res of Object.keys(manifest.resources)) { + console.log(`Signing ${res}...`); + let fileName; + switch (res) { + case 'js': + fileName = 'plugin.js'; + break; + case 'css': + fileName = 'plugin.css'; + break; + case 'loc': + fileName = manifest.locale.name + '.json'; + break; + } + fileName = path.join(packageName, fileName); + const sign = crypto.createSign('RSA-SHA256'); + sign.write(fs.readFileSync(fileName)); + sign.end(); + manifest.resources[res] = sign.sign(privateKey).toString('base64'); + } + fs.writeFileSync(path.join(packageName, 'manifest.json'), JSON.stringify(manifest, null, 2)); + console.log('Done, package manifest updated'); +}