Эх сурвалжийг харах

Merge branch 'release/0.6.0'

Book Pauk 6 жил өмнө
parent
commit
a0ccc7fe07
34 өөрчлөгдсөн 2281 нэмэгдсэн , 445 устгасан
  1. 9 1
      build/webpack.prod.config.js
  2. 15 2
      client/api/reader.js
  3. 22 15
      client/components/Reader/HistoryPage/HistoryPage.vue
  4. 185 105
      client/components/Reader/Reader.vue
  5. 611 0
      client/components/Reader/ServerStorage/ServerStorage.vue
  6. 324 8
      client/components/Reader/SettingsPage/SettingsPage.vue
  7. 14 11
      client/components/Reader/TextPage/TextPage.vue
  8. 13 3
      client/components/Reader/share/BookParser.js
  9. 178 55
      client/components/Reader/share/bookManager.js
  10. 0 70
      client/components/Reader/share/restoreOldSettings.js
  11. 6 0
      client/element.js
  12. 26 0
      client/share/cryptoUtils.js
  13. 60 0
      client/share/sjcl.js
  14. 146 0
      client/share/sjclWrapper.js
  15. 105 11
      client/share/utils.js
  16. 74 45
      client/store/modules/reader.js
  17. 17 5
      package-lock.json
  18. 5 1
      package.json
  19. 13 1
      server/config/base.js
  20. 1 2
      server/controllers/BaseController.js
  21. 23 6
      server/controllers/ReaderController.js
  22. 5 0
      server/core/BookConverter/ConvertBase.js
  23. 43 2
      server/core/BookConverter/ConvertHtml.js
  24. 2 0
      server/core/BookConverter/ConvertSamlib.js
  25. 2 2
      server/core/BookConverter/sax.js
  26. 0 91
      server/core/SqliteConnectionPool.js
  27. 118 0
      server/core/readerStorage.js
  28. 188 0
      server/db/SqliteConnectionPool.js
  29. 50 0
      server/db/connManager.js
  30. 5 0
      server/db/migrations/app/index.js
  31. 7 0
      server/db/migrations/readerStorage/001-create.js
  32. 6 0
      server/db/migrations/readerStorage/index.js
  33. 3 5
      server/index.js
  34. 5 4
      server/routes.js

+ 9 - 1
build/webpack.prod.config.js

@@ -32,7 +32,15 @@ module.exports = merge(baseWpConfig, {
     },
     optimization: {
         minimizer: [
-            new TerserPlugin(),
+            new TerserPlugin({
+                cache: true,
+                parallel: true,
+                terserOptions: {
+                    output: {
+                        comments: false,
+                    },
+                },
+            }),
             new OptimizeCSSAssetsPlugin()
         ]
     },

+ 15 - 2
client/api/reader.js

@@ -1,5 +1,8 @@
+import _ from 'lodash';
 import axios from 'axios';
-import {sleep} from '../share/utils';
+import {Buffer} from 'safe-buffer';
+
+import * as utils from '../share/utils';
 
 const api = axios.create({
   baseURL: '/api/reader'
@@ -41,7 +44,7 @@ class Reader {
                 throw new Error(errMes);
             }
             if (i > 0)
-                await sleep(refreshPause);
+                await utils.sleep(refreshPause);
 
             i++;
             if (i > 120*1000/refreshPause) {//2 мин ждем телодвижений воркера
@@ -106,6 +109,16 @@ class Reader {
 
         return url;
     }
+
+    async storage(request) {
+        let response = await api.post('/storage', request);
+
+        const state = response.data.state;
+        if (!state)
+            throw new Error('Неверный ответ api');
+
+        return response.data;
+    }
 }
 
 export default new Reader();

+ 22 - 15
client/components/Reader/HistoryPage/HistoryPage.vue

@@ -127,7 +127,6 @@ class HistoryPage extends Vue {
 
     init() {
         this.updateTableData();
-        this.mostRecentBook = bookManager.mostRecentBook();
         this.$nextTick(() => {
             this.$refs.input.focus();
         });
@@ -141,9 +140,11 @@ class HistoryPage extends Vue {
         let result = [];
 
         const sorted = bookManager.getSortedRecent();
-        const len = (sorted.length < 100 ? sorted.length : 100);
-        for (let i = 0; i < len; i++) {
+        for (let i = 0; i < sorted.length; i++) {
             const book = sorted[i];
+            if (book.deleted)
+                continue;
+
             let d = new Date();
             d.setTime(book.touchTime);
             const t = formatDate(d).split(' ');
@@ -164,11 +165,21 @@ class HistoryPage extends Vue {
             else
                 title = '';
 
-            let author = _.compact([
-                fb2.lastName,
-                fb2.firstName,
-                fb2.middleName
-            ]).join(' ');
+            let author = '';
+            if (fb2.author) {
+                const authorNames = fb2.author.map(a => _.compact([
+                    a.lastName,
+                    a.firstName,
+                    a.middleName
+                ]).join(' '));
+                author = authorNames.join(', ');
+            } else {
+                author = _.compact([
+                    fb2.lastName,
+                    fb2.firstName,
+                    fb2.middleName
+                ]).join(' ');
+            }
             author = (author ? author : (fb2.bookTitle ? fb2.bookTitle : book.url));
 
             result.push({
@@ -183,6 +194,8 @@ class HistoryPage extends Vue {
                 path: book.path,
                 key: book.key,
             });
+            if (result.length >= 100)
+                break;
         }
 
         const search = this.search;
@@ -225,13 +238,7 @@ class HistoryPage extends Vue {
         await bookManager.delRecentBook({key});
         this.updateTableData();
 
-        const newRecent = bookManager.mostRecentBook();
-
-        if (!(this.mostRecentBook && newRecent && this.mostRecentBook.key == newRecent.key))
-            this.$emit('load-book', newRecent);
-
-        this.mostRecentBook = newRecent;
-        if (!this.mostRecentBook)
+        if (!bookManager.mostRecentBook())
             this.close();
     }
 

+ 185 - 105
client/components/Reader/Reader.vue

@@ -73,6 +73,7 @@
             <SettingsPage v-if="settingsActive" ref="settingsPage" @settings-toggle="settingsToggle"></SettingsPage>
             <HelpPage v-if="helpActive" ref="helpPage" @help-toggle="helpToggle"></HelpPage>
             <ClickMapPage v-show="clickMapActive" ref="clickMapPage"></ClickMapPage>
+            <ServerStorage v-show="hidden" ref="serverStorage"></ServerStorage>
         </el-main>
     </el-container>
 </template>
@@ -81,6 +82,9 @@
 //-----------------------------------------------------------------------------
 import Vue from 'vue';
 import Component from 'vue-class-component';
+import _ from 'lodash';
+import {Buffer} from 'safe-buffer';
+
 import LoaderPage from './LoaderPage/LoaderPage.vue';
 import TextPage from './TextPage/TextPage.vue';
 import ProgressPage from './ProgressPage/ProgressPage.vue';
@@ -92,12 +96,11 @@ import HistoryPage from './HistoryPage/HistoryPage.vue';
 import SettingsPage from './SettingsPage/SettingsPage.vue';
 import HelpPage from './HelpPage/HelpPage.vue';
 import ClickMapPage from './ClickMapPage/ClickMapPage.vue';
+import ServerStorage from './ServerStorage/ServerStorage.vue';
 
 import bookManager from './share/bookManager';
 import readerApi from '../../api/reader';
-import _ from 'lodash';
-import {sleep} from '../../share/utils';
-import restoreOldSettings from './share/restoreOldSettings';
+import * as utils from '../../share/utils';
 
 export default @Component({
     components: {
@@ -112,6 +115,7 @@ export default @Component({
         SettingsPage,
         HelpPage,
         ClickMapPage,
+        ServerStorage,
     },
     watch: {
         bookPos: function(newValue) {
@@ -166,6 +170,7 @@ class Reader extends Vue {
 
     actionList = [];
     actionCur = -1;
+    hidden = false;
 
     created() {
         this.loading = true;
@@ -192,6 +197,18 @@ class Reader extends Vue {
             }
         }, 500);
 
+        this.debouncedSaveRecent = _.debounce(async() => {
+            const serverStorage = this.$refs.serverStorage;
+            while (!serverStorage.inited) await utils.sleep(1000);
+            await serverStorage.saveRecent();
+        }, 1000);
+
+        this.debouncedSaveRecentLast = _.debounce(async() => {
+            const serverStorage = this.$refs.serverStorage;
+            while (!serverStorage.inited) await utils.sleep(1000);
+            await serverStorage.saveRecentLast();
+        }, 1000);
+
         document.addEventListener('fullscreenchange', () => {
             this.fullScreenActive = (document.fullscreenElement !== null);
         });
@@ -202,15 +219,17 @@ class Reader extends Vue {
     mounted() {
         (async() => {
             await bookManager.init(this.settings);
-            await restoreOldSettings(this.settings, bookManager, this.commit);
+            bookManager.addEventListener(this.bookManagerEvent);
 
             if (this.$root.rootRoute == '/reader') {
                 if (this.routeParamUrl) {
-                    this.loadBook({url: this.routeParamUrl, bookPos: this.routeParamPos});
+                    await this.loadBook({url: this.routeParamUrl, bookPos: this.routeParamPos});
                 } else {
                     this.loaderActive = true;
                 }
             }
+
+            this.checkSetStorageAccessKey();
             this.loading = false;
         })();
     }
@@ -224,6 +243,20 @@ class Reader extends Vue {
         this.blinkCachedLoad = settings.blinkCachedLoad;
     }
 
+    checkSetStorageAccessKey() {
+        const q = this.$route.query;
+
+        if (q['setStorageAccessKey']) {
+            this.$router.replace(`/reader`);
+            this.settingsToggle();
+            this.$nextTick(() => {
+                this.$refs.settingsPage.enterServerStorageKey(
+                    Buffer.from(utils.fromBase58(q['setStorageAccessKey'])).toString()
+                );
+            });
+        }
+    }
+
     get routeParamPos() {
         let result = undefined;
         const q = this.$route.query;
@@ -237,6 +270,8 @@ class Reader extends Vue {
     }
 
     updateRoute(isNewRoute) {
+        if (this.loading)
+            return;
         const recent = this.mostRecentBook();
         const pos = (recent && recent.bookPos && this.allowUrlParamBookPos ? `__p=${recent.bookPos}&` : '');
         const url = (recent ? `url=${recent.url}` : '');
@@ -265,6 +300,45 @@ class Reader extends Vue {
         this.debouncedUpdateRoute();
     }
 
+    async bookManagerEvent(eventName) {
+        const serverStorage = this.$refs.serverStorage;
+        if (eventName == 'load-meta-finish') {
+            serverStorage.init();
+            const result = await bookManager.cleanRecentBooks();
+            if (result)
+                this.debouncedSaveRecent();
+        }
+
+        if (eventName == 'recent-changed' || eventName == 'save-recent') {
+            if (this.historyActive) {
+                this.$refs.historyPage.updateTableData();
+            }
+
+            const oldBook = this.mostRecentBookReactive;
+            const newBook = bookManager.mostRecentBook();
+
+            if (oldBook && newBook) {
+                if (oldBook.key != newBook.key) {
+                    this.loadingBook = true;
+                    try {
+                        await this.loadBook(newBook);
+                    } finally {
+                        this.loadingBook = false;
+                    }
+                } else if (oldBook.bookPos != newBook.bookPos) {
+                    while (this.loadingBook) await utils.sleep(100);
+                    this.bookPosChanged({bookPos: newBook.bookPos});
+                }
+            }
+
+            if (eventName == 'recent-changed') {
+                this.debouncedSaveRecentLast();
+            } else {
+                this.debouncedSaveRecent();
+            }
+        }
+    }
+
     get toolBarActive() {
         return this.reader.toolBarActive;
     }
@@ -584,6 +658,11 @@ class Reader extends Vue {
             this.$root.$emit('set-app-title');
         }
 
+        // на LoaderPage всегда показываем toolBar
+        if (result == 'LoaderPage' && !this.toolBarActive) {
+            this.toolBarToggle();
+        }
+
         if (this.lastActivePage != result && result == 'TextPage') {
             //акивируем страницу с текстом
             this.$nextTick(async() => {
@@ -609,7 +688,7 @@ class Reader extends Vue {
         return result;
     }
 
-    loadBook(opts) {
+    async loadBook(opts) {
         if (!opts || !opts.url) {
             this.mostRecentBook();
             return;
@@ -628,119 +707,120 @@ class Reader extends Vue {
         }
 
         this.progressActive = true;
-        this.$nextTick(async() => {
-            const progress = this.$refs.page;
-
-            this.actionList = [];
-            this.actionCur = -1;
-
-            try {
-                progress.show();
-                progress.setState({state: 'parse'});
-
-                // есть ли среди недавних
-                const key = bookManager.keyFromUrl(url);
-                let wasOpened = await bookManager.getRecentBook({key});
-                wasOpened = (wasOpened ? wasOpened : {});
-                const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
-                const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
-                const bookPosPercent = wasOpened.bookPosPercent;
-
-                let book = null;
-
-                if (!opts.force) {
-                    // пытаемся загрузить и распарсить книгу в менеджере из локального кэша
-                    const bookParsed = await bookManager.getBook({url}, (prog) => {
-                        progress.setState({progress: prog});
-                    });
-
-                    // если есть в локальном кэше
-                    if (bookParsed) {
-                        await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, bookParsed));
-                        this.mostRecentBook();
-                        this.addAction(bookPos);
-                        this.loaderActive = false;
-                        progress.hide(); this.progressActive = false;
-                        this.blinkCachedLoadMessage();
-
-                        await this.activateClickMapPage();
-                        return;
-                    }
 
-                    // иначе идем на сервер
-                    // пытаемся загрузить готовый файл с сервера
-                    if (wasOpened.path) {
-                        try {
-                            const resp = await readerApi.loadCachedBook(wasOpened.path, (state) => {
-                                progress.setState(state);
-                            });
-                            book = Object.assign({}, wasOpened, {data: resp.data});
-                        } catch (e) {
-                            //молчим
-                        }
-                    }
-                }
+        await this.$nextTick()
 
-                progress.setState({totalSteps: 5});
+        const progress = this.$refs.page;
 
-                // не удалось, скачиваем книгу полностью с конвертацией
-                let loadCached = true;
-                if (!book) {
-                    book = await readerApi.loadBook(url, (state) => {
-                        progress.setState(state);
-                    });
-                    loadCached = false;
-                }
+        this.actionList = [];
+        this.actionCur = -1;
+
+        try {
+            progress.show();
+            progress.setState({state: 'parse'});
+
+            // есть ли среди недавних
+            const key = bookManager.keyFromUrl(url);
+            let wasOpened = await bookManager.getRecentBook({key});
+            wasOpened = (wasOpened ? wasOpened : {});
+            const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
+            const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
 
-                // добавляем в bookManager
-                progress.setState({state: 'parse', step: 5});
-                const addedBook = await bookManager.addBook(book, (prog) => {
+            let book = null;
+
+            if (!opts.force) {
+                // пытаемся загрузить и распарсить книгу в менеджере из локального кэша
+                const bookParsed = await bookManager.getBook({url}, (prog) => {
                     progress.setState({progress: prog});
                 });
 
-                // добавляем в историю
-                await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, addedBook));
-                this.mostRecentBook();
-                this.addAction(bookPos);
-                this.updateRoute(true);
-
-                this.loaderActive = false;
-                progress.hide(); this.progressActive = false;
-                if (loadCached) {
+                // если есть в локальном кэше
+                if (bookParsed) {
+                    await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen}, bookParsed));
+                    this.mostRecentBook();
+                    this.addAction(bookPos);
+                    this.loaderActive = false;
+                    progress.hide(); this.progressActive = false;
                     this.blinkCachedLoadMessage();
-                } else
-                    this.stopBlink = true;
-
-                await this.activateClickMapPage();
-            } catch (e) {
-                progress.hide(); this.progressActive = false;
-                this.loaderActive = true;
-                this.$alert(e.message, 'Ошибка', {type: 'error'});
+
+                    await this.activateClickMapPage();
+                    return;
+                }
+
+                // иначе идем на сервер
+                // пытаемся загрузить готовый файл с сервера
+                if (wasOpened.path) {
+                    try {
+                        const resp = await readerApi.loadCachedBook(wasOpened.path, (state) => {
+                            progress.setState(state);
+                        });
+                        book = Object.assign({}, wasOpened, {data: resp.data});
+                    } catch (e) {
+                        //молчим
+                    }
+                }
             }
-        });
-    }
 
-    loadFile(opts) {
-        this.progressActive = true;
-        this.$nextTick(async() => {
-            const progress = this.$refs.page;
-            try {
-                progress.show();
-                progress.setState({state: 'upload'});
+            progress.setState({totalSteps: 5});
 
-                const url = await readerApi.uploadFile(opts.file, this.config.maxUploadFileSize, (state) => {
+            // не удалось, скачиваем книгу полностью с конвертацией
+            let loadCached = true;
+            if (!book) {
+                book = await readerApi.loadBook(url, (state) => {
                     progress.setState(state);
                 });
+                loadCached = false;
+            }
 
-                progress.hide(); this.progressActive = false;
+            // добавляем в bookManager
+            progress.setState({state: 'parse', step: 5});
+            const addedBook = await bookManager.addBook(book, (prog) => {
+                progress.setState({progress: prog});
+            });
 
-                this.loadBook({url});
-            } catch (e) {
-                progress.hide(); this.progressActive = false;
-                this.loaderActive = true;
-                this.$alert(e.message, 'Ошибка', {type: 'error'});
-            }
-        });
+            // добавляем в историю
+            await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen}, addedBook));
+            this.mostRecentBook();
+            this.addAction(bookPos);
+            this.updateRoute(true);
+
+            this.loaderActive = false;
+            progress.hide(); this.progressActive = false;
+            if (loadCached) {
+                this.blinkCachedLoadMessage();
+            } else
+                this.stopBlink = true;
+
+            await this.activateClickMapPage();
+        } catch (e) {
+            progress.hide(); this.progressActive = false;
+            this.loaderActive = true;
+            this.$alert(e.message, 'Ошибка', {type: 'error'});
+        }
+    }
+
+    async loadFile(opts) {
+        this.progressActive = true;
+
+        await this.$nextTick();
+
+        const progress = this.$refs.page;
+        try {
+            progress.show();
+            progress.setState({state: 'upload'});
+
+            const url = await readerApi.uploadFile(opts.file, this.config.maxUploadFileSize, (state) => {
+                progress.setState(state);
+            });
+
+            progress.hide(); this.progressActive = false;
+
+            await this.loadBook({url});
+        } catch (e) {
+            progress.hide(); this.progressActive = false;
+            this.loaderActive = true;
+            this.$alert(e.message, 'Ошибка', {type: 'error'});
+        }
     }
 
     blinkCachedLoadMessage() {
@@ -757,7 +837,7 @@ class Reader extends Vue {
                     this.showRefreshIcon = !this.showRefreshIcon;
                     if (page.blinkCachedLoadMessage)
                         page.blinkCachedLoadMessage(this.showRefreshIcon);
-                    await sleep(500);
+                    await utils.sleep(500);
                     if (this.stopBlink)
                         break;
                     this.blinkCount--;

+ 611 - 0
client/components/Reader/ServerStorage/ServerStorage.vue

@@ -0,0 +1,611 @@
+<template>
+    <div></div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import _ from 'lodash';
+
+import bookManager from '../share/bookManager';
+import readerApi from '../../../api/reader';
+import * as utils from '../../../share/utils';
+import * as cryptoUtils from '../../../share/cryptoUtils';
+
+const maxSetTries = 5;
+
+export default @Component({
+    watch: {
+        serverSyncEnabled: function() {
+            this.serverSyncEnabledChanged();
+        },
+        serverStorageKey: function() {
+            this.serverStorageKeyChanged(true);
+        },
+        settings: function() {
+            this.debouncedSaveSettings();
+        },
+        profiles: function() {
+            this.saveProfiles();
+        },
+        currentProfile: function() {
+            this.currentProfileChanged(true);
+        },
+    },
+})
+class ServerStorage extends Vue {
+    created() {
+        this.inited = false;
+        this.commit = this.$store.commit;
+        this.prevServerStorageKey = null;
+        this.$root.$on('generateNewServerStorageKey', () => {this.generateNewServerStorageKey()});
+
+        this.debouncedSaveSettings = _.debounce(() => {
+            this.saveSettings();
+        }, 500);
+
+        this.oldProfiles = {};
+        this.oldSettings = {};
+        this.oldRecent = {};
+        this.oldRecentLast = {};
+    }
+
+    async init() {
+        try {
+            if (!this.serverStorageKey) {
+                //генерируем новый ключ
+                await this.generateNewServerStorageKey();
+            } else {
+                await this.serverStorageKeyChanged();
+            }
+            this.oldRecent = _.cloneDeep(bookManager.recent);
+            this.oldRecentLast = _.cloneDeep(bookManager.recentLast) || {};
+        } finally {
+            this.inited = true;
+        }
+    }
+
+    async generateNewServerStorageKey() {
+        const key = utils.toBase58(utils.randomArray(32));
+        this.commit('reader/setServerStorageKey', key);
+        await this.serverStorageKeyChanged(true);
+    }
+
+    async serverSyncEnabledChanged() {
+        if (this.serverSyncEnabled) {
+            this.prevServerStorageKey = null;
+            await this.serverStorageKeyChanged(true);
+        }
+    }
+
+    async serverStorageKeyChanged(force) {
+        if (this.prevServerStorageKey != this.serverStorageKey) {
+            this.prevServerStorageKey = this.serverStorageKey;
+            this.hashedStorageKey = utils.toBase58(cryptoUtils.sha256(this.serverStorageKey));
+
+            await this.loadProfiles(force);
+            this.checkCurrentProfile();
+            await this.currentProfileChanged(force);
+            await this.loadRecent(force);
+            if (force)
+                await this.saveRecent();
+        }
+    }
+
+    async currentProfileChanged(force) {
+        if (!this.currentProfile)
+            return;
+
+        await this.loadSettings(force);
+    }
+
+    get serverSyncEnabled() {
+        return this.$store.state.reader.serverSyncEnabled;
+    }
+
+    get settings() {
+        return this.$store.state.reader.settings;
+    }
+
+    get settingsRev() {
+        return this.$store.state.reader.settingsRev;
+    }
+
+    get serverStorageKey() {
+        return this.$store.state.reader.serverStorageKey;
+    }
+
+    get profiles() {
+        return this.$store.state.reader.profiles;
+    }
+
+    get profilesRev() {
+        return this.$store.state.reader.profilesRev;
+    }
+
+    get currentProfile() {
+        return this.$store.state.reader.currentProfile;
+    }
+
+    get showServerStorageMessages() {
+        return this.settings.showServerStorageMessages;
+    }
+
+    checkCurrentProfile() {
+        if (!this.profiles[this.currentProfile]) {
+            this.commit('reader/setCurrentProfile', '');
+        }
+    }
+
+    notifySuccess() {
+        this.success('Данные синхронизированы с сервером');
+    }
+
+    success(message) {
+        if (this.showServerStorageMessages)
+            this.$notify.success({message});
+    }
+
+    warning(message) {
+        if (this.showServerStorageMessages)
+            this.$notify.warning({message});
+    }
+
+    error(message) {
+        if (this.showServerStorageMessages)
+            this.$notify.error({message});
+    }
+
+    async loadSettings(force) {
+        if (!this.serverSyncEnabled || !this.currentProfile)
+            return;
+
+        const setsId = `settings-${this.currentProfile}`;
+        const oldRev = this.settingsRev[setsId] || 0;
+        //проверим ревизию на сервере
+        if (!force) {
+            try {
+                const revs = await this.storageCheck({[setsId]: {}});
+                if (revs.state == 'success' && revs.items[setsId].rev == oldRev) {
+                    return;
+                }
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером: ${e.message}`);
+                return;
+            }
+        }
+
+        let sets = null;
+        try {
+            sets = await this.storageGet({[setsId]: {}});
+        } catch(e) {
+            this.error(`Ошибка соединения с сервером: ${e.message}`);
+            return;
+        }
+
+        if (sets.state == 'success') {
+            sets = sets.items[setsId];
+
+            if (sets.rev == 0)
+                sets.data = {};
+
+            this.oldSettings = _.cloneDeep(sets.data);
+            this.commit('reader/setSettings', sets.data);
+            this.commit('reader/setSettingsRev', {[setsId]: sets.rev});
+
+            this.notifySuccess();
+        } else {
+            this.warning(`Неверный ответ сервера: ${sets.state}`);
+        }
+    }
+
+    async saveSettings() {
+        if (!this.serverSyncEnabled || !this.currentProfile || this.savingSettings)
+            return;
+
+        const diff = utils.getObjDiff(this.oldSettings, this.settings);
+        if (utils.isEmptyObjDiff(diff))
+            return;
+
+        this.savingSettings = true;
+        try {
+            const setsId = `settings-${this.currentProfile}`;
+            let result = {state: ''};
+            let tries = 0;
+            while (result.state != 'success' && tries < maxSetTries) {
+                const oldRev = this.settingsRev[setsId] || 0;
+                try {
+                    result = await this.storageSet({[setsId]: {rev: oldRev + 1, data: this.settings}});
+                } catch(e) {
+                    this.savingSettings = false;
+                    this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
+                    return;
+                }
+
+                if (result.state == 'reject') {
+                    await this.loadSettings(true);
+                    const newSettings = utils.applyObjDiff(this.settings, diff);
+                    this.commit('reader/setSettings', newSettings);
+                }
+
+                tries++;
+            }
+
+            if (tries >= maxSetTries) {
+                //отменять изменения не будем, просто предупредим
+                //this.commit('reader/setSettings', this.oldSettings);
+                console.error(result);
+                this.error('Не удалось отправить настройки на сервер. Данные не сохранены и могут быть перезаписаны.');
+            } else {
+                this.oldSettings = _.cloneDeep(this.settings);
+                this.commit('reader/setSettingsRev', {[setsId]: this.settingsRev[setsId] + 1});
+            }
+        } finally {
+            this.savingSettings = false;
+        }
+    }
+
+    async loadProfiles(force) {
+        if (!this.serverSyncEnabled)
+            return;
+
+        const oldRev = this.profilesRev;
+        //проверим ревизию на сервере
+        if (!force) {
+            try {
+                const revs = await this.storageCheck({profiles: {}});
+                if (revs.state == 'success' && revs.items.profiles.rev == oldRev) {
+                    return;
+                }
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером: ${e.message}`);
+                return;
+            }
+        }
+
+        let prof = null;
+        try {
+            prof = await this.storageGet({profiles: {}});
+        } catch(e) {
+            this.error(`Ошибка соединения с сервером: ${e.message}`);
+            return;
+        }
+
+        if (prof.state == 'success') {
+            prof = prof.items.profiles;
+
+            if (prof.rev == 0)
+                prof.data = {};
+
+            this.oldProfiles = _.cloneDeep(prof.data);
+            this.commit('reader/setProfiles', prof.data);
+            this.commit('reader/setProfilesRev', prof.rev);
+
+            this.notifySuccess();
+        } else {
+            this.warning(`Неверный ответ сервера: ${prof.state}`);
+        }
+    }
+
+    async saveProfiles() {
+        if (!this.serverSyncEnabled || this.savingProfiles)
+            return;
+
+        const diff = utils.getObjDiff(this.oldProfiles, this.profiles);
+        if (utils.isEmptyObjDiff(diff))
+            return;
+
+        //обнуляются профили во время разработки, подстраховка
+        if (!this.$store.state.reader.allowProfilesSave) {
+            console.error('Сохранение профилей не санкционировано');
+            return;
+        }
+
+        this.savingProfiles = true;
+        try {
+            let result = {state: ''};
+            let tries = 0;
+            while (result.state != 'success' && tries < maxSetTries) {
+                try {
+                    result = await this.storageSet({profiles: {rev: this.profilesRev + 1, data: this.profiles}});
+                } catch(e) {
+                    this.savingProfiles = false;
+                    this.commit('reader/setProfiles', this.oldProfiles);
+                    this.checkCurrentProfile();
+                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения отменены.`);
+                    return;
+                }
+
+                if (result.state == 'reject') {
+                    await this.loadProfiles(true);
+                    const newProfiles = utils.applyObjDiff(this.profiles, diff);
+                    this.commit('reader/setProfiles', newProfiles);
+                }
+
+                tries++;
+            }
+
+            if (tries >= maxSetTries) {
+                this.commit('reader/setProfiles', this.oldProfiles);
+                this.checkCurrentProfile();
+                console.error(result);
+                this.error('Не удалось отправить данные на сервер. Изменения отменены.');
+            } else {
+                this.oldProfiles = _.cloneDeep(this.profiles);
+                this.commit('reader/setProfilesRev', this.profilesRev + 1);        
+            }
+        } finally {
+            this.savingProfiles = false;
+        }
+    }
+
+    async loadRecent(force) {
+        if (!this.serverSyncEnabled)
+            return;
+
+        const oldRev = bookManager.recentRev;
+        const oldLastRev = bookManager.recentLastRev;
+        //проверим ревизию на сервере
+        let revs = null;
+        if (!force) {
+            try {
+                revs = await this.storageCheck({recent: {}, recentLast: {}});
+                if (revs.state == 'success' && revs.items.recent.rev == oldRev &&
+                    revs.items.recentLast.rev == oldLastRev) {
+                    return;
+                }
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером: ${e.message}`);
+                return;
+            }
+        }
+
+        if (force || revs.items.recent.rev != oldRev) {
+            let recent = null;
+            try {
+                recent = await this.storageGet({recent: {}});
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером: ${e.message}`);
+                return;
+            }
+
+            if (recent.state == 'success') {
+                recent = recent.items.recent;
+
+                if (recent.rev == 0)
+                    recent.data = {};
+
+                this.oldRecent = _.cloneDeep(recent.data);
+                await bookManager.setRecent(recent.data);
+                await bookManager.setRecentRev(recent.rev);
+            } else {
+                this.warning(`Неверный ответ сервера: ${recent.state}`);
+            }
+        }
+
+        if (force || revs.items.recentLast.rev != oldLastRev) {
+            let recentLast = null;
+            try {
+                recentLast = await this.storageGet({recentLast: {}});
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером: ${e.message}`);
+                return;
+            }
+
+            if (recentLast.state == 'success') {
+                recentLast = recentLast.items.recentLast;
+
+                if (recentLast.rev == 0)
+                    recentLast.data = {};
+
+                this.oldRecentLast = _.cloneDeep(recentLast.data);
+                await bookManager.setRecentLast(recentLast.data);
+                await bookManager.setRecentLastRev(recentLast.rev);
+            } else {
+                this.warning(`Неверный ответ сервера: ${recentLast.state}`);
+            }
+        }
+
+        this.notifySuccess();
+    }
+
+    async saveRecent() {
+        if (!this.serverSyncEnabled || this.savingRecent)
+            return;
+
+        const bm = bookManager;
+
+        const diff = utils.getObjDiff(this.oldRecent, bm.recent);
+        if (utils.isEmptyObjDiff(diff))
+            return;
+
+        this.savingRecent = true;
+        try {
+            let result = {state: ''};
+            let tries = 0;
+            while (result.state != 'success' && tries < maxSetTries) {
+                try {
+                    result = await this.storageSet({recent: {rev: bm.recentRev + 1, data: bm.recent}});
+                } catch(e) {
+                    this.savingRecent = false;
+                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
+                    return;
+                }
+
+                if (result.state == 'reject') {
+                    await this.loadRecent(true);
+                    //похоже это лишнее
+                    /*const newRecent = utils.applyObjDiff(bm.recent, diff);
+                    await bm.setRecent(newRecent);*/
+                }
+
+                tries++;
+            }
+
+            if (tries >= maxSetTries) {
+                console.error(result);
+                this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
+            } else {
+                this.oldRecent = _.cloneDeep(bm.recent);
+                await bm.setRecentRev(bm.recentRev + 1);
+                await this.saveRecentLast(true);
+            }
+        } finally {
+            this.savingRecent = false;
+        }
+    }
+
+    async saveRecentLast(force = false) {
+        if (!this.serverSyncEnabled || this.savingRecentLast)
+            return;
+
+        const bm = bookManager;
+        let recentLast = bm.recentLast;
+        recentLast = (recentLast ? recentLast : {});
+        let lastRev = bm.recentLastRev;
+
+        const diff = utils.getObjDiff(this.oldRecentLast, recentLast);
+        if (utils.isEmptyObjDiff(diff))
+            return;
+
+        this.savingRecentLast = true;
+        try {
+            let result = {state: ''};
+            let tries = 0;
+            while (result.state != 'success' && tries < maxSetTries) {
+                if (force) {
+                    try {
+                        const revs = await this.storageCheck({recentLast: {}});
+                        if (revs.items.recentLast.rev)
+                            lastRev = revs.items.recentLast.rev;
+                    } catch(e) {
+                        this.error(`Ошибка соединения с сервером: ${e.message}`);
+                        return;
+                    }
+                }
+
+                try {
+                    result = await this.storageSet({recentLast: {rev: lastRev + 1, data: recentLast}}, force);
+                } catch(e) {
+                    this.savingRecentLast = false;
+                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
+                    return;
+                }
+
+                if (result.state == 'reject') {
+                    await this.loadRecent(false);
+                    this.savingRecentLast = false;//!!!
+                    return;//!!!
+                }
+
+                tries++;
+            }
+
+            if (tries >= maxSetTries) {
+                console.error(result);
+                this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
+            } else {
+                this.oldRecentLast = _.cloneDeep(recentLast);
+                await bm.setRecentLastRev(lastRev + 1);
+            }
+        } finally {
+            this.savingRecentLast = false;
+        }
+    }
+
+    async storageCheck(items) {
+        return await this.storageApi('check', items);
+    }
+
+    async storageGet(items) {
+        return await this.storageApi('get', items);
+    }
+
+    async storageSet(items, force) {
+        return await this.storageApi('set', items, force);
+    }
+
+    async storageApi(action, items, force) {
+        const request = {action, items};
+        if (force)
+            request.force = true;
+        const encodedRequest = await this.encodeStorageItems(request);
+        return await this.decodeStorageItems(await readerApi.storage(encodedRequest));
+    }
+
+    async encodeStorageItems(request) {
+        if (!this.hashedStorageKey)
+            throw new Error('hashedStorageKey is empty');
+
+        if (!_.isObject(request.items))
+            throw new Error('items is not an object');
+
+        let result = Object.assign({}, request);
+        let items = {};
+        for (const id of Object.keys(request.items)) {
+            const item = request.items[id];
+            if (request.action == 'set' && !_.isObject(item.data))
+                throw new Error('encodeStorageItems: data is not an object');
+
+            let encoded = Object.assign({}, item);
+
+            if (item.data) {
+                const comp = utils.pako.deflate(JSON.stringify(item.data), {level: 1});
+                let encrypted = null;
+                try {
+                    encrypted = cryptoUtils.aesEncrypt(comp, this.serverStorageKey);
+                } catch (e) {
+                    throw new Error('encrypt failed');
+                }
+                encoded.data = '1' + utils.toBase64(encrypted);
+            }
+            items[`${this.hashedStorageKey}.${utils.toBase58(id)}`] = encoded;
+        }
+
+        result.items = items;
+        return result;
+    }
+
+    async decodeStorageItems(response) {
+        if (!this.hashedStorageKey)
+            throw new Error('hashedStorageKey is empty');
+
+        let result = Object.assign({}, response);
+        let items = {};
+        if (response.items) {
+            if (!_.isObject(response.items))
+                throw new Error('items is not an object');
+
+            for (const id of Object.keys(response.items)) {
+                const item = response.items[id];
+                let decoded = Object.assign({}, item);
+                if (item.data) {
+                    if (!_.isString(item.data) || !item.data.length)
+                        throw new Error('decodeStorageItems: data is not a string');
+                    if (item.data[0] !== '1')
+                        throw new Error('decodeStorageItems: unknown data format');
+
+                    const a = utils.fromBase64(item.data.substr(1));
+                    let decrypted = null;
+                    try {
+                        decrypted = cryptoUtils.aesDecrypt(a, this.serverStorageKey);
+                    } catch (e) {
+                        throw new Error('decrypt failed');
+                    }
+                    decoded.data = JSON.parse(utils.pako.inflate(decrypted, {to: 'string'}));
+                }
+
+                const ids = id.split('.');
+                if (!(ids.length == 2) || !(ids[0] == this.hashedStorageKey))
+                    throw new Error(`decodeStorageItems: bad id - ${id}`);
+                items[utils.fromBase58(ids[1])] = decoded;
+            }
+        }
+
+        result.items = items;
+        return result;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>

+ 324 - 8
client/components/Reader/SettingsPage/SettingsPage.vue

@@ -7,7 +7,106 @@
                 </template>
 
                 <el-tabs type="border-card" tab-position="left" v-model="selectedTab">
-                    <!--------------------------------------------------------------------------->
+                    <!-- Профили ------------------------------------------------------------------------->
+                    <el-tab-pane label="Профили">
+                        <el-form :model="form" size="small" label-width="80px" @submit.native.prevent>
+                            <div class="partHeader">Управление синхронизацией данных</div>
+                            <el-form-item label="">
+                                <el-checkbox v-model="serverSyncEnabled">Включить синхронизацию с сервером</el-checkbox>
+                            </el-form-item>
+                        </el-form>
+
+                        <div v-show="serverSyncEnabled">
+                        <el-form :model="form" size="small" label-width="80px" @submit.native.prevent>
+                            <div class="partHeader">Профили устройств</div>
+
+                            <el-form-item label="">
+                                <div class="text">
+                                    Выберите или добавьте профиль устройства, чтобы начать синхронизацию настроек с сервером.
+                                    <br>При выборе "Нет" синхронизация настроек (но не книг) отключается.
+                                </div>
+                            </el-form-item>
+
+                            <el-form-item label="Устройство">
+                                <el-select v-model="currentProfile" placeholder="">
+                                    <el-option label="Нет" value=""></el-option>
+                                    <el-option v-for="item in profilesArray"
+                                        :key="item"
+                                        :label="item"
+                                        :value="item">
+                                    </el-option>
+                                </el-select>
+                            </el-form-item>
+
+                            <el-form-item label="">
+                                    <el-button @click="addProfile">Добавить</el-button>
+                                    <el-button @click="delProfile">Удалить</el-button>
+                                    <el-button @click="delAllProfiles">Удалить все</el-button>
+                            </el-form-item>
+                        </el-form>
+
+                        <el-form :model="form" size="small" label-width="80px" @submit.native.prevent>
+                            <div class="partHeader">Ключ доступа</div>
+
+                            <el-form-item label="">
+                                <div class="text">
+                                    Ключ доступа позволяет восстановить профили с настройками и список читаемых книг.
+                                    Для этого необходимо передать ключ на новое устройство через почту, мессенджер или другим способом.
+                                </div>
+                            </el-form-item>
+
+                            <el-form-item label="">
+                                    <el-button style="width: 250px" @click="showServerStorageKey">
+                                        <span v-show="serverStorageKeyVisible">Скрыть</span>
+                                        <span v-show="!serverStorageKeyVisible">Показать</span>
+                                        ключ доступа
+                                 </el-button>
+                            </el-form-item>
+
+                            <el-form-item label="">
+                                <div v-if="!serverStorageKeyVisible">
+                                    <hr/>
+                                    <b>{{ partialStorageKey }}</b> (часть вашего ключа)
+                                    <hr/>
+                                </div>
+                                <div v-else style="line-height: 100%">
+                                    <hr/>
+                                    <div style="width: 300px; padding-top: 5px; overflow-wrap: break-word;"><b>{{ serverStorageKey }}</b></div>
+                                    <br><div class="center">
+                                        <el-button size="mini" class="copy-button" @click="copyToClip(serverStorageKey, 'Ключ')">Скопировать ключ</el-button>
+                                    </div>
+                                    <div v-if="mode == 'omnireader'">
+                                        <br>Переход по ссылке позволит автоматически ввести ключ доступа:
+                                        <br><div class="center" style="margin-top: 5px">
+                                            <a :href="setStorageKeyLink" target="_blank">Ссылка для ввода ключа</a>
+                                        </div>
+                                        <br><div class="center">
+                                            <el-button size="mini" class="copy-button" @click="copyToClip(setStorageKeyLink, 'Ссылка')">Скопировать ссылку</el-button>
+                                        </div>
+                                    </div>
+                                    <hr/>
+                                </div>
+                            </el-form-item>
+
+                            <el-form-item label="">
+                                    <el-button style="width: 250px" @click="enterServerStorageKey">Ввести ключ доступа</el-button>
+                            </el-form-item>
+                            <el-form-item label="">
+                                    <el-button style="width: 250px" @click="generateServerStorageKey">Сгенерировать новый ключ</el-button>
+                            </el-form-item>
+
+                            <el-form-item label="">
+                                <div class="text">
+                                    Рекомендуется сохранить ключ в надежном месте, чтобы всегда иметь возможность восстановить настройки,
+                                    например, после переустановки ОС или чистки/смены браузера.<br>
+                                    <b>ПРЕДУПРЕЖДЕНИЕ!</b> При утере ключа, НИКТО не сможет восстановить ваши данные, т.к. они сжимаются
+                                    и шифруются ключом доступа перед отправкой на сервер.
+                                </div>
+                            </el-form-item>
+                        </el-form>
+                        </div>
+                    </el-tab-pane>
+                    <!-- Вид ------------------------------------------------------------------------->                    
                     <el-tab-pane label="Вид">
 
                         <el-form :model="form" size="small" label-width="120px" @submit.native.prevent>
@@ -246,7 +345,7 @@
                         </el-form>
                     </el-tab-pane>
 
-                    <!--------------------------------------------------------------------------->
+                    <!-- Листание ------------------------------------------------------------------------->
                     <el-tab-pane label="Листание">
                         <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
                             <div class="partHeader">Анимация</div>
@@ -283,12 +382,13 @@
                         </el-form>
                         
                     </el-tab-pane>
-                    <!--------------------------------------------------------------------------->
+                    <!-- Прочее ------------------------------------------------------------------------->
                     <el-tab-pane label="Прочее">
                         <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
                             <el-form-item label="Управление">
                                 <el-checkbox v-model="clickControl">Включить управление кликом</el-checkbox>
                             </el-form-item>
+
                             <el-form-item label="Подсказка">
                                 <el-tooltip :open-delay="500" effect="light">
                                     <template slot="content">
@@ -306,6 +406,16 @@
                                     <el-checkbox v-model="blinkCachedLoad">Предупреждать о загрузке из кэша</el-checkbox>
                                 </el-tooltip>
                             </el-form-item>
+                            <el-form-item label="Уведомления">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Показывать уведомления и ошибки от<br>
+                                        синхронизатора данных с сервером
+                                    </template>
+                                    <el-checkbox v-model="showServerStorageMessages">Показывать сообщения синхронизации</el-checkbox>
+                                </el-tooltip>
+                            </el-form-item>
+
                             <el-form-item label="URL">
                                 <el-tooltip :open-delay="500" effect="light">
                                     <template slot="content">
@@ -340,7 +450,7 @@
                             </el-form-item>
                         </el-form>
                     </el-tab-pane>
-                    <!--------------------------------------------------------------------------->
+                    <!-- Сброс ------------------------------------------------------------------------->
                     <el-tab-pane label="Сброс">
                         <el-button @click="setDefaults">Установить по-умолчанию</el-button>
                     </el-tab-pane>
@@ -355,7 +465,9 @@
 //-----------------------------------------------------------------------------
 import Vue from 'vue';
 import Component from 'vue-class-component';
+import _ from 'lodash';
 
+import * as utils from '../../../share/utils';
 import Window from '../../share/Window.vue';
 import rstore from '../../../store/modules/reader';
 
@@ -367,6 +479,9 @@ export default @Component({
         return Object.assign({}, rstore.settingDefaults);
     },
     watch: {
+        settings: function() {
+            this.settingsChanged();
+        },
         form: function(newValue) {
             this.commit('reader/setSettings', newValue);
         },
@@ -401,10 +516,19 @@ class SettingsPage extends Vue {
     webFonts = [];
     fonts = [];
 
+    serverStorageKeyVisible = false;
+
     created() {
         this.commit = this.$store.commit;
         this.reader = this.$store.state.reader;
 
+        this.form = {};
+        this.settingsChanged();
+    }
+
+    settingsChanged() {
+        if (_.isEqual(this.form, this.settings))
+            return;
         this.form = Object.assign({}, this.settings);
         for (let prop in rstore.settingDefaults) {
             this[prop] = this.form[prop];
@@ -421,10 +545,52 @@ class SettingsPage extends Vue {
         this.vertShift = this.fontShifts[font] || 0;
     }
 
+    get mode() {
+        return this.$store.state.config.mode;
+    }
+
     get settings() {
         return this.$store.state.reader.settings;
     }
 
+    get serverSyncEnabled() {
+        return this.$store.state.reader.serverSyncEnabled;
+    }
+
+    set serverSyncEnabled(newValue) {
+        this.commit('reader/setServerSyncEnabled', newValue);
+    }
+
+    get profiles() {
+        return this.$store.state.reader.profiles;
+    }
+
+    get profilesArray() {
+        const result = Object.keys(this.profiles)
+        result.sort();
+        return result;
+    }
+
+    get currentProfile() {
+        return this.$store.state.reader.currentProfile;
+    }
+
+    set currentProfile(newValue) {
+        this.commit('reader/setCurrentProfile', newValue);
+    }
+
+    get partialStorageKey() {
+        return this.serverStorageKey.substr(0, 7) + '***';
+    }
+
+    get serverStorageKey() {
+        return this.$store.state.reader.serverStorageKey;
+    }
+
+    get setStorageKeyLink() {
+        return `http://${window.location.host}/#/reader?setStorageAccessKey=${utils.toBase58(this.serverStorageKey)}`;
+    }
+
     get predefineTextColors() {
         return [
           '#ffffff',
@@ -461,10 +627,10 @@ class SettingsPage extends Vue {
 
     async setDefaults() {
         try {
-            if (await this.$confirm('Подтвердите установку настроек по-умолчанию', '', {
-              confirmButtonText: 'OK',
-              cancelButtonText: 'Отмена',
-              type: 'warning'
+            if (await this.$confirm('Подтвердите установку настроек по-умолчанию:', '', {
+                confirmButtonText: 'OK',
+                cancelButtonText: 'Отмена',
+                type: 'warning'
             })) {
                 this.form = Object.assign({}, rstore.settingDefaults);
                 for (let prop in rstore.settingDefaults) {
@@ -476,6 +642,148 @@ class SettingsPage extends Vue {
         }
     }
 
+    async addProfile() {
+        try {
+            if (Object.keys(this.profiles).length >= 100) {
+                this.$alert('Достигнут предел количества профилей', 'Ошибка');
+                return;
+            }
+            const result = await this.$prompt('Введите произвольное название для профиля устройства:', '', {
+                confirmButtonText: 'OK',
+                cancelButtonText: 'Отмена',
+                inputValidator: (str) => { if (!str) return 'Название не должно быть пустым'; else if (str.length > 50) return 'Слишком длинное название'; else return true; },
+            });
+            if (result.value) {
+                if (this.profiles[result.value]) {
+                    this.$alert('Такой профиль уже существует', 'Ошибка');
+                } else {
+                    const newProfiles = Object.assign({}, this.profiles, {[result.value]: 1});
+                    this.commit('reader/setAllowProfilesSave', true);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setProfiles', newProfiles);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setAllowProfilesSave', false);
+                    this.currentProfile = result.value;
+                }
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async delProfile() {
+        if (!this.currentProfile)
+            return;
+
+        try {
+            const result = await this.$prompt(`<b>Предупреждение!</b> Удаление профиля '${this.currentProfile}' необратимо.` +
+                    `<br>Все настройки профиля будут потеряны,<br>однако список читаемых книг сохранится.` +
+                    `<br><br>Введите 'да' для подтверждения удаления:`, '', {
+                dangerouslyUseHTMLString: true,
+                confirmButtonText: 'OK',
+                cancelButtonText: 'Отмена',
+                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
+                type: 'warning'
+            });
+
+            if (result.value && result.value.toLowerCase() == 'да') {
+                if (this.profiles[this.currentProfile]) {
+                    const newProfiles = Object.assign({}, this.profiles);
+                    delete newProfiles[this.currentProfile];
+                    this.commit('reader/setAllowProfilesSave', true);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setProfiles', newProfiles);
+                    await this.$nextTick();//ждем обработчики watch
+                    this.commit('reader/setAllowProfilesSave', false);
+                    this.currentProfile = '';
+                }
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async delAllProfiles() {
+        if (!Object.keys(this.profiles).length)
+            return;
+
+        try {
+            const result = await this.$prompt(`<b>Предупреждение!</b> Удаление ВСЕХ профилей с настройками необратимо.` +
+                    `<br><br>Введите 'да' для подтверждения удаления:`, '', {
+                dangerouslyUseHTMLString: true,
+                confirmButtonText: 'OK',
+                cancelButtonText: 'Отмена',
+                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
+                type: 'warning'
+            });
+
+            if (result.value && result.value.toLowerCase() == 'да') {
+                this.commit('reader/setAllowProfilesSave', true);
+                await this.$nextTick();//ждем обработчики watch
+                this.commit('reader/setProfiles', {});
+                await this.$nextTick();//ждем обработчики watch
+                this.commit('reader/setAllowProfilesSave', false);
+                this.currentProfile = '';
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async copyToClip(text, prefix) {
+        const result = await utils.copyTextToClipboard(text);
+        const suf = (prefix.substr(-1) == 'а' ? 'а' : '');
+        const msg = (result ? `${prefix} успешно скопирован${suf} в буфер обмена` : 'Копирование не удалось');
+        if (result)
+            this.$notify.success({message: msg});
+        else
+            this.$notify.error({message: msg});
+    }
+
+    async showServerStorageKey() {
+        this.serverStorageKeyVisible = !this.serverStorageKeyVisible;
+    }
+
+    async enterServerStorageKey(key) {
+        try {
+            const result = await this.$prompt(`<b>Предупреждение!</b> Изменение ключа доступа приведет к замене всех профилей и читаемых книг в читалке.` +
+                    `<br><br>Введите новый ключ доступа:`, '', {
+                dangerouslyUseHTMLString: true,
+                confirmButtonText: 'OK',
+                cancelButtonText: 'Отмена',
+                inputValidator: (str) => { if (str && str.length == 44) return true; else return 'Неверный формат ключа'; },
+                inputValue: (key && _.isString(key) ? key : null),
+                type: 'warning'
+            });
+
+            if (result.value && result.value.length == 44) {
+                this.commit('reader/setServerStorageKey', result.value);
+            }
+        } catch (e) {
+            //
+        }
+    }
+
+    async generateServerStorageKey() {
+        try {
+            const result = await this.$prompt(`<b>Предупреждение!</b> Генерация нового ключа доступа приведет к удалению всех профилей и читаемых книг в читалке.` +
+                    `<br><br>Введите 'да' для подтверждения генерации нового ключа:`, '', {
+                dangerouslyUseHTMLString: true,
+                confirmButtonText: 'OK',
+                cancelButtonText: 'Отмена',
+                inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Генерация не подтверждена'; },
+                type: 'warning'
+            });
+
+            if (result.value && result.value.toLowerCase() == 'да') {
+                this.$root.$emit('generateNewServerStorageKey');
+            }
+        } catch (e) {
+            //
+        }
+
+    }
+
     keyHook(event) {
         if (event.type == 'keydown' && event.code == 'Escape') {
             this.close();
@@ -504,6 +812,11 @@ class SettingsPage extends Vue {
     position: relative;
 }
 
+.text {
+    font-size: 90%;
+    line-height: 130%;
+}
+
 .el-form {
     border-top: 2px solid #bbbbbb;
     margin-bottom: 5px;
@@ -540,4 +853,7 @@ class SettingsPage extends Vue {
     padding: 15px;
 }
 
+.center {
+    text-align: center;
+}
 </style>

+ 14 - 11
client/components/Reader/TextPage/TextPage.vue

@@ -161,7 +161,6 @@ class TextPage extends Vue {
         this.h = this.scrollHeight - 2*this.indentTB;
         this.lineHeight = this.fontSize + this.lineInterval;
         this.pageLineCount = 1 + Math.floor((this.h - this.lineHeight + this.lineInterval/2)/this.lineHeight);
-        this.pageSpace = this.scrollHeight - this.pageLineCount*this.lineHeight;
 
         this.$refs.scrollingPage1.style.width = this.w + 'px';
         this.$refs.scrollingPage2.style.width = this.w + 'px';
@@ -195,7 +194,6 @@ class TextPage extends Vue {
         this.drawHelper.indentLR = this.indentLR;
         this.drawHelper.textAlignJustify = this.textAlignJustify;
         this.drawHelper.lineHeight = this.lineHeight;
-        this.drawHelper.pageSpace = this.pageSpace;
         this.drawHelper.context = this.context;
 
         //сообщение "Загрузка шрифтов..."
@@ -235,15 +233,16 @@ class TextPage extends Vue {
         this.statusBarClickable = this.drawHelper.statusBarClickable(this.statusBarTop, this.statusBarHeight);
 
         //scrolling page
-        let y = this.pageSpace/2;
+        const pageSpace = this.scrollHeight - this.pageLineCount*this.lineHeight;
+        let y = pageSpace/2;
         if (this.showStatusBar)
             y += this.statusBarHeight*(this.statusBarTop ? 1 : 0);
         let page1 = this.$refs.scrollBox1;
         let page2 = this.$refs.scrollBox2;
         page1.style.width = this.w + this.indentLR + 'px';
         page2.style.width = this.w + this.indentLR + 'px';
-        page1.style.height = this.scrollHeight - (this.pageSpace > 0 ? this.pageSpace : 0) + 'px';
-        page2.style.height = this.scrollHeight - (this.pageSpace > 0 ? this.pageSpace : 0) + 'px';
+        page1.style.height = this.scrollHeight - (pageSpace > 0 ? pageSpace : 0) + 'px';
+        page2.style.height = this.scrollHeight - (pageSpace > 0 ? pageSpace : 0) + 'px';
         page1.style.top = y + 'px';
         page2.style.top = y + 'px';
         page1.style.left = this.indentLR + 'px';
@@ -378,13 +377,17 @@ class TextPage extends Vue {
                 this.meta = bookManager.metaOnly(this.book);
                 this.fb2 = this.meta.fb2;
 
-                const authorName = _.compact([
-                    this.fb2.lastName,
-                    this.fb2.firstName,
-                    this.fb2.middleName
-                ]).join(' ');
+                let authorNames = [];
+                if (this.fb2.author) {
+                    authorNames = this.fb2.author.map(a => _.compact([
+                        a.lastName,
+                        a.firstName,
+                        a.middleName
+                    ]).join(' '));
+                }
+
                 this.title = _.compact([
-                    authorName,
+                    authorNames.join(', '),
                     this.fb2.bookTitle
                 ]).join(' - ');
 

+ 13 - 3
client/components/Reader/share/BookParser.js

@@ -201,6 +201,12 @@ export default class BookParser {
                 }
             }
 
+            if (elemName == 'author' && path.indexOf('/fictionbook/description/title-info/author') == 0) {
+                if (!fb2.author)
+                    fb2.author = [];
+                fb2.author.push({});
+            }
+
             if (path.indexOf('/fictionbook/body') == 0) {
                 if (tag == 'body') {
                     if (!isFirstBody)
@@ -319,15 +325,19 @@ export default class BookParser {
 
             text = text.replace(/[\t\n\r\xa0]/g, ' ');
 
+            const authorLength = (fb2.author && fb2.author.length ? fb2.author.length : 0);
             switch (path) {
                 case '/fictionbook/description/title-info/author/first-name':
-                    fb2.firstName = text;
+                    if (authorLength)
+                        fb2.author[authorLength - 1].firstName = text;
                     break;
                 case '/fictionbook/description/title-info/author/middle-name':
-                    fb2.middleName = text;
+                    if (authorLength)
+                        fb2.author[authorLength - 1].middleName = text;
                     break;
                 case '/fictionbook/description/title-info/author/last-name':
-                    fb2.lastName = text;
+                    if (authorLength)
+                        fb2.author[authorLength - 1].lastName = text;
                     break;
                 case '/fictionbook/description/title-info/genre':
                     fb2.genre = text;

+ 178 - 55
client/components/Reader/share/bookManager.js

@@ -1,9 +1,10 @@
 import localForage from 'localforage';
+import _ from 'lodash';
 
 import * as utils from '../../../share/utils';
 import BookParser from './BookParser';
 
-const maxDataSize = 500*1024*1024;//chars, not bytes
+const maxDataSize = 300*1024*1024;//compressed bytes
 
 const bmMetaStore = localForage.createInstance({
     name: 'bmMetaStore'
@@ -25,6 +26,8 @@ class BookManager {
     async init(settings) {
         this.settings = settings;
 
+        this.eventListeners = [];
+
         //bmCacheStore нужен только для ускорения загрузки читалки
         this.booksCached = await bmCacheStore.getItem('books');
         if (!this.booksCached)
@@ -33,10 +36,10 @@ class BookManager {
         this.recentLast = await bmCacheStore.getItem('recent-last');
         if (this.recentLast)
             this.recent[this.recentLast.key] = this.recentLast;
-
+        this.recentRev = await bmRecentStore.getItem('recent-rev') || 0;
+        this.recentLastRev = await bmRecentStore.getItem('recent-last-rev') || 0;
         this.books = Object.assign({}, this.booksCached);
 
-        this.recentChanged1 = true;
         this.recentChanged2 = true;
 
         if (!this.books || !this.recent) {
@@ -63,25 +66,44 @@ class BookManager {
             if (keySplit.length == 2 && keySplit[0] == 'bmMeta') {
                 let meta = await bmMetaStore.getItem(key);
 
-                const oldBook = this.books[meta.key];
-                this.books[meta.key] = meta;
+                if (_.isObject(meta)) {
+                    const oldBook = this.books[meta.key];
+                    this.books[meta.key] = meta;
 
-                if (oldBook && oldBook.data && oldBook.parsed) {
-                    this.books[meta.key].data = oldBook.data;
-                    this.books[meta.key].parsed = oldBook.parsed;
+                    if (oldBook && oldBook.parsed) {
+                        this.books[meta.key].parsed = oldBook.parsed;
+                    }
+                } else {
+                    await bmMetaStore.removeItem(key);
                 }
             }
         }
 
+        let key = null;
         len = await bmRecentStore.length();
         for (let i = 0; i < len; i++) {
-            const key = await bmRecentStore.key(i);
-            let r = await bmRecentStore.getItem(key);
-            this.recent[r.key] = r;
+            key = await bmRecentStore.key(i);
+            if (key) {
+                let r = await bmRecentStore.getItem(key);
+                if (_.isObject(r) && r.key) {
+                    this.recent[r.key] = r;
+                }
+            } else  {
+                await bmRecentStore.removeItem(key);
+            }
         }
 
+        //размножение для дебага
+        /*if (key) {
+            for (let i = 0; i < 1000; i++) {
+                const k = this.keyFromUrl(i.toString());
+                this.recent[k] = Object.assign({}, _.cloneDeep(this.recent[key]), {key: k, touchTime: Date.now() - 1000000});
+            }
+        }*/
         await this.cleanBooks();
-        await this.cleanRecentBooks();
+
+        //очистка позже
+        //await this.cleanRecentBooks();
 
         this.booksCached = {};
         for (const key in this.books) {
@@ -89,6 +111,7 @@ class BookManager {
         }
         await bmCacheStore.setItem('books', this.booksCached);
         await bmCacheStore.setItem('recent', this.recent);
+        this.emit('load-meta-finish');
     }
 
     async cleanBooks() {
@@ -98,7 +121,8 @@ class BookManager {
             let toDel = null;
             for (let key in this.books) {
                 let book = this.books[key];
-                size += (book.length ? book.length : 0);
+                const bookLength = (book.length ? book.length : 0);
+                size += (book.dataCompressedLength ? book.dataCompressedLength : bookLength);
 
                 if (book.addTime < min) {
                     toDel = book;
@@ -107,7 +131,7 @@ class BookManager {
             }
 
             if (size > maxDataSize && toDel) {
-                await this.delBook(toDel);
+                await this._delBook(toDel);
             } else {
                 break;
             }
@@ -121,15 +145,29 @@ class BookManager {
         meta.key = this.keyFromUrl(meta.url);
         meta.addTime = Date.now();
 
-        const result = await this.parseBook(meta, newBook.data, callback);
+        const cb = (perc) => {
+            const p = Math.round(80*perc/100);
+            callback(p);
+        };
+
+        const result = await this.parseBook(meta, newBook.data, cb);
+        result.dataCompressed = true;
+
+        let data = newBook.data;
+        if (result.dataCompressed) {
+            data = utils.pako.deflate(data, {level: 9});
+            result.dataCompressedLength = data.byteLength;
+        }
+        callback(90);
 
         this.books[meta.key] = result;
         this.booksCached[meta.key] = this.metaOnly(result);
 
         await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result));
-        await bmDataStore.setItem(`bmData-${meta.key}`, result.data);
+        await bmDataStore.setItem(`bmData-${meta.key}`, data);
         await bmCacheStore.setItem('books', this.booksCached);
 
+        callback(100);
         return result;
     }
 
@@ -150,30 +188,44 @@ class BookManager {
         let result = undefined;
         if (!meta.key)
             meta.key = this.keyFromUrl(meta.url);
-        result = this.books[meta.key];
 
-        if (result && !result.data) {
-            result.data = await bmDataStore.getItem(`bmData-${meta.key}`);
-            this.books[meta.key] = result;
-        }
+        result = this.books[meta.key];
 
         if (result && !result.parsed) {
-            result = await this.parseBook(result, result.data, callback);
+            let data = await bmDataStore.getItem(`bmData-${meta.key}`);
+            callback(10);
+            await utils.sleep(10);
+
+            if (result.dataCompressed) {
+                data = utils.pako.inflate(data, {to: 'string'});
+            }
+            callback(20);
+
+            const cb = (perc) => {
+                const p = 20 + Math.round(80*perc/100);
+                callback(p);
+            };
+
+            result = await this.parseBook(result, data, cb);
             this.books[meta.key] = result;
         }
 
         return result;
     }
 
-    async delBook(meta) {
-        if (!this.books) 
-            await this.init();
-
+    async _delBook(meta) {
         await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
         await bmDataStore.removeItem(`bmData-${meta.key}`);
 
         delete this.books[meta.key];
         delete this.booksCached[meta.key];
+    }
+
+    async delBook(meta) {
+        if (!this.books) 
+            await this.init();
+
+        await this._delBook(meta);
 
         await bmCacheStore.setItem('books', this.booksCached);
     }
@@ -188,7 +240,6 @@ class BookManager {
         const result = Object.assign({}, meta, parsedMeta, {
             length: data.length,
             textLength: parsed.textLength,
-            data,
             parsed
         });
 
@@ -197,7 +248,7 @@ class BookManager {
 
     metaOnly(book) {
         let result = Object.assign({}, book);
-        delete result.data;
+        delete result.data;//можно будет убрать эту строку со временем
         delete result.parsed;
         return result;
     }
@@ -206,29 +257,40 @@ class BookManager {
         return utils.stringToHex(url);
     }
 
-    async setRecentBook(value, noTouch) {
+    async setRecentBook(value) {
         if (!this.recent) 
             await this.init();
         const result = this.metaOnly(value);
-        if (!noTouch)
-            Object.assign(result, {touchTime: Date.now()});
-
-        if (result.textLength && !result.bookPos && result.bookPosPercent)
-            result.bookPos = Math.round(result.bookPosPercent*result.textLength);
+        result.touchTime = Date.now();
+        result.deleted = 0;
+
+        if (this.recent[result.key] && this.recent[result.key].deleted) {
+            //восстановим из небытия пользовательские данные
+            if (!result.bookPos)
+                result.bookPos = this.recent[result.key].bookPos;
+            if (!result.bookPosSeen)
+                result.bookPosSeen = this.recent[result.key].bookPosSeen;
+        }
 
         this.recent[result.key] = result;
 
         await bmRecentStore.setItem(result.key, result);
 
         //кэшируем, аккуратно
+        let saveRecent = false;
         if (!(this.recentLast && this.recentLast.key == result.key)) {
             await bmCacheStore.setItem('recent', this.recent);
+            saveRecent = true;
         }
         this.recentLast = result;
         await bmCacheStore.setItem('recent-last', this.recentLast);
 
-        this.recentChanged1 = true;
+        this.mostRecentCached = result;
         this.recentChanged2 = true;
+
+        if (saveRecent)
+            this.emit('save-recent');
+        this.emit('recent-changed');
         return result;
     }
 
@@ -242,38 +304,37 @@ class BookManager {
         if (!this.recent) 
             await this.init();
 
-        await bmRecentStore.removeItem(value.key);
-        delete this.recent[value.key];
+        this.recent[value.key].deleted = 1;
+        await bmRecentStore.setItem(value.key, this.recent[value.key]);
         await bmCacheStore.setItem('recent', this.recent);
 
-        this.recentChanged1 = true;
+        this.mostRecentCached = null;
         this.recentChanged2 = true;
+
+        this.emit('save-recent');
     }
 
     async cleanRecentBooks() {
         if (!this.recent) 
             await this.init();
 
-        if (Object.keys(this.recent).length > 1000) {
-            let min = Date.now();
-            let found = null;
-            for (let key in this.recent) {
-                const book = this.recent[key];
-                if (book.touchTime < min) {
-                    min = book.touchTime;
-                    found = book;
-                }
-            }
+        const sorted = this.getSortedRecent();
 
-            if (found) {
-                await this.delRecentBook(found);
-                await this.cleanRecentBooks();
-            }
+        let isDel = false;
+        for (let i = 1000; i < sorted.length; i++) {
+            await bmRecentStore.removeItem(sorted[i].key);
+            delete this.recent[sorted[i].key];
+            isDel = true;
         }
+
+        this.sortedRecentCached = null;
+        await bmCacheStore.setItem('recent', this.recent);
+
+        return isDel;
     }
 
     mostRecentBook() {
-        if (!this.recentChanged1 && this.mostRecentCached) {
+        if (this.mostRecentCached) {
             return this.mostRecentCached;
         }
 
@@ -281,13 +342,12 @@ class BookManager {
         let result = null;
         for (let key in this.recent) {
             const book = this.recent[key];
-            if (book.touchTime > max) {
+            if (!book.deleted && book.touchTime > max) {
                 max = book.touchTime;
                 result = book;
             }
         }
         this.mostRecentCached = result;
-        this.recentChanged1 = false;
         return result;
     }
 
@@ -305,6 +365,69 @@ class BookManager {
         return result;
     }
 
+    async setRecent(value) {
+        const mergedRecent = _.cloneDeep(this.recent);
+
+        Object.assign(mergedRecent, value);
+        const newRecent = {};
+        for (const rec of Object.values(mergedRecent)) {
+            if (rec.key) {
+                await bmRecentStore.setItem(rec.key, rec);
+                newRecent[rec.key] = rec;
+            }
+        }
+
+        this.recent = newRecent;
+        await bmCacheStore.setItem('recent', this.recent);
+
+        this.recentLast = null;
+        await bmCacheStore.setItem('recent-last', this.recentLast);
+
+        this.mostRecentCached = null;
+        this.emit('recent-changed');
+    }
+
+    async setRecentRev(value) {
+        await bmRecentStore.setItem('recent-rev', value);
+        this.recentRev = value;
+    }
+
+    async setRecentLast(value) {
+        if (!value.key)
+            value = null;
+
+        this.recentLast = value;
+        await bmCacheStore.setItem('recent-last', this.recentLast);
+        if (value && value.key) {
+            this.recent[value.key] = value;
+            await bmRecentStore.setItem(value.key, value);
+            await bmCacheStore.setItem('recent', this.recent);
+        }
+
+        this.mostRecentCached = null;
+        this.emit('recent-changed');
+    }
+
+    async setRecentLastRev(value) {
+        bmRecentStore.setItem('recent-last-rev', value);
+        this.recentLastRev = value;
+    }
+
+    addEventListener(listener) {
+        if (this.eventListeners.indexOf(listener) < 0)
+            this.eventListeners.push(listener);        
+    }
+
+    removeEventListener(listener) {
+        const i = this.eventListeners.indexOf(listener);
+        if (i >= 0)
+            this.eventListeners.splice(i, 1);
+    }
+
+    emit(eventName, value) {
+        for (const listener of this.eventListeners)
+            listener(eventName, value);
+    }
 
 }
 

+ 0 - 70
client/components/Reader/share/restoreOldSettings.js

@@ -1,70 +0,0 @@
-export default async function restoreOldSettings(settings, bookManager, commit) {
-    const oldSets = localStorage['colorSetting'];
-    let isOld = false;
-    for (let i = 0; i < localStorage.length; i++) {
-        let key = unescape(localStorage.key(i));
-        if (key.indexOf('bpr-book-') == 0)
-            isOld = true;
-    }
-
-    if (isOld || oldSets) {
-        let newSettings = null;
-        if (oldSets) {
-            const [textColor, backgroundColor, lineStep, , , statusBarHeight, scInt] = unescape(oldSets).split('|');
-
-            const fontSize = Math.round(lineStep*0.8);
-            const scrollingDelay = fontSize*scInt;
-
-            newSettings = Object.assign({}, settings, {
-                textColor,
-                backgroundColor,
-                fontSize,
-                statusBarHeight: statusBarHeight*1,
-                scrollingDelay,
-            });
-        }
-
-        for (let i = 0; i < localStorage.length; i++) {
-            let key = localStorage.key(i);
-            if (key.indexOf('bpr-') == 0) {
-                let v = unescape(localStorage[key]);
-                key = unescape(key);
-
-                if (key.lastIndexOf('=timestamp') == key.length - 10) {
-                    continue;
-                }
-
-                if (key.indexOf('bpr-book-') == 0) {
-                    const url = key.substr(9);
-                    const [scrollTop, scrollHeight, ] = v.split('|');
-
-                    const bookPosPercent = scrollTop*1/(scrollHeight*1 + 1);
-                    const title = unescape(localStorage[`bpr-title-${escape(url)}`]);
-                    const author = unescape(localStorage[`bpr-author-${escape(url)}`]);
-                    const time = unescape(localStorage[`bpr-book-${escape(url)}=timestamp`]).split(';')[0];
-                    const touchTime = Date.parse(time);
-
-                    const bookKey = bookManager.keyFromUrl(url);
-                    const recent = await bookManager.getRecentBook({key: bookKey});
-
-                    if (!recent) {
-                        await bookManager.setRecentBook({
-                            key: bookKey,
-                            touchTime,
-                            bookPosPercent,
-                            url,
-                            fb2: {
-                                bookTitle: title,
-                                lastName: author,
-                            }
-                        }, true);
-                    }
-                }
-            }
-        }
-
-        localStorage.clear();
-        if (oldSets)
-            commit('reader/setSettings', newSettings);
-    }
-}

+ 6 - 0
client/element.js

@@ -86,6 +86,9 @@ import './theme/form-item.css';
 import ElColorPicker from 'element-ui/lib/color-picker';
 import './theme/color-picker.css';
 
+//import ElDialog from 'element-ui/lib/dialog';
+//import './theme/dialog.css';
+
 import Notification from 'element-ui/lib/notification';
 import './theme/notification.css';
 
@@ -95,6 +98,9 @@ import './theme/loading.css';
 import MessageBox from 'element-ui/lib/message-box';
 import './theme/message-box.css';
 
+//import Message from 'element-ui/lib/message';
+//import './theme/message.css';
+
 const components = {
     ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip,
     ElCol, ElContainer, ElAside, ElMain, ElHeader,

+ 26 - 0
client/share/cryptoUtils.js

@@ -0,0 +1,26 @@
+//WebCrypto API (crypto.subtle) не работает без https, поэтому приходится извращаться через sjcl
+import sjclWrapper from './sjclWrapper';
+
+//не менять
+const iv = 'B6E2XejNh2dS';
+const salt = 'Liberama project is awesome';
+
+export function aesEncrypt(data, password) {
+    return sjclWrapper.codec.bytes.fromBits(
+        sjclWrapper.encryptArray(
+            password, sjclWrapper.codec.bytes.toBits(data), {iv, salt}
+        ).ct
+    );
+}
+
+export function aesDecrypt(data, password) {
+    return sjclWrapper.codec.bytes.fromBits(
+        sjclWrapper.decryptArray(
+            password, {ct: sjclWrapper.codec.bytes.toBits(data)}, {iv, salt}
+        )
+    );
+}
+
+export function sha256(str) {
+    return sjclWrapper.codec.bytes.fromBits(sjclWrapper.hash.sha256.hash(str));
+}

+ 60 - 0
client/share/sjcl.js

@@ -0,0 +1,60 @@
+"use strict";var sjcl={cipher:{},hash:{},keyexchange:{},mode:{},misc:{},codec:{},exception:{corrupt:function(a){this.toString=function(){return"CORRUPT: "+this.message};this.message=a},invalid:function(a){this.toString=function(){return"INVALID: "+this.message};this.message=a},bug:function(a){this.toString=function(){return"BUG: "+this.message};this.message=a},notReady:function(a){this.toString=function(){return"NOT READY: "+this.message};this.message=a}}};
+sjcl.cipher.aes=function(a){this.s[0][0][0]||this.O();var b,c,d,e,f=this.s[0][4],g=this.s[1];b=a.length;var h=1;if(4!==b&&6!==b&&8!==b)throw new sjcl.exception.invalid("invalid aes key size");this.b=[d=a.slice(0),e=[]];for(a=b;a<4*b+28;a++){c=d[a-1];if(0===a%b||8===b&&4===a%b)c=f[c>>>24]<<24^f[c>>16&255]<<16^f[c>>8&255]<<8^f[c&255],0===a%b&&(c=c<<8^c>>>24^h<<24,h=h<<1^283*(h>>7));d[a]=d[a-b]^c}for(b=0;a;b++,a--)c=d[b&3?a:a-4],e[b]=4>=a||4>b?c:g[0][f[c>>>24]]^g[1][f[c>>16&255]]^g[2][f[c>>8&255]]^g[3][f[c&
+255]]};
+sjcl.cipher.aes.prototype={encrypt:function(a){return t(this,a,0)},decrypt:function(a){return t(this,a,1)},s:[[[],[],[],[],[]],[[],[],[],[],[]]],O:function(){var a=this.s[0],b=this.s[1],c=a[4],d=b[4],e,f,g,h=[],k=[],l,n,m,p;for(e=0;0x100>e;e++)k[(h[e]=e<<1^283*(e>>7))^e]=e;for(f=g=0;!c[f];f^=l||1,g=k[g]||1)for(m=g^g<<1^g<<2^g<<3^g<<4,m=m>>8^m&255^99,c[f]=m,d[m]=f,n=h[e=h[l=h[f]]],p=0x1010101*n^0x10001*e^0x101*l^0x1010100*f,n=0x101*h[m]^0x1010100*m,e=0;4>e;e++)a[e][f]=n=n<<24^n>>>8,b[e][m]=p=p<<24^p>>>8;for(e=
+0;5>e;e++)a[e]=a[e].slice(0),b[e]=b[e].slice(0)}};
+function t(a,b,c){if(4!==b.length)throw new sjcl.exception.invalid("invalid aes block size");var d=a.b[c],e=b[0]^d[0],f=b[c?3:1]^d[1],g=b[2]^d[2];b=b[c?1:3]^d[3];var h,k,l,n=d.length/4-2,m,p=4,r=[0,0,0,0];h=a.s[c];a=h[0];var q=h[1],v=h[2],w=h[3],x=h[4];for(m=0;m<n;m++)h=a[e>>>24]^q[f>>16&255]^v[g>>8&255]^w[b&255]^d[p],k=a[f>>>24]^q[g>>16&255]^v[b>>8&255]^w[e&255]^d[p+1],l=a[g>>>24]^q[b>>16&255]^v[e>>8&255]^w[f&255]^d[p+2],b=a[b>>>24]^q[e>>16&255]^v[f>>8&255]^w[g&255]^d[p+3],p+=4,e=h,f=k,g=l;for(m=
+0;4>m;m++)r[c?3&-m:m]=x[e>>>24]<<24^x[f>>16&255]<<16^x[g>>8&255]<<8^x[b&255]^d[p++],h=e,e=f,f=g,g=b,b=h;return r}
+sjcl.bitArray={bitSlice:function(a,b,c){a=sjcl.bitArray.$(a.slice(b/32),32-(b&31)).slice(1);return void 0===c?a:sjcl.bitArray.clamp(a,c-b)},extract:function(a,b,c){var d=Math.floor(-b-c&31);return((b+c-1^b)&-32?a[b/32|0]<<32-d^a[b/32+1|0]>>>d:a[b/32|0]>>>d)&(1<<c)-1},concat:function(a,b){if(0===a.length||0===b.length)return a.concat(b);var c=a[a.length-1],d=sjcl.bitArray.getPartial(c);return 32===d?a.concat(b):sjcl.bitArray.$(b,d,c|0,a.slice(0,a.length-1))},bitLength:function(a){var b=a.length;return 0===
+b?0:32*(b-1)+sjcl.bitArray.getPartial(a[b-1])},clamp:function(a,b){if(32*a.length<b)return a;a=a.slice(0,Math.ceil(b/32));var c=a.length;b=b&31;0<c&&b&&(a[c-1]=sjcl.bitArray.partial(b,a[c-1]&2147483648>>b-1,1));return a},partial:function(a,b,c){return 32===a?b:(c?b|0:b<<32-a)+0x10000000000*a},getPartial:function(a){return Math.round(a/0x10000000000)||32},equal:function(a,b){if(sjcl.bitArray.bitLength(a)!==sjcl.bitArray.bitLength(b))return!1;var c=0,d;for(d=0;d<a.length;d++)c|=a[d]^b[d];return 0===
+c},$:function(a,b,c,d){var e;e=0;for(void 0===d&&(d=[]);32<=b;b-=32)d.push(c),c=0;if(0===b)return d.concat(a);for(e=0;e<a.length;e++)d.push(c|a[e]>>>b),c=a[e]<<32-b;e=a.length?a[a.length-1]:0;a=sjcl.bitArray.getPartial(e);d.push(sjcl.bitArray.partial(b+a&31,32<b+a?c:d.pop(),1));return d},i:function(a,b){return[a[0]^b[0],a[1]^b[1],a[2]^b[2],a[3]^b[3]]},byteswapM:function(a){var b,c;for(b=0;b<a.length;++b)c=a[b],a[b]=c>>>24|c>>>8&0xff00|(c&0xff00)<<8|c<<24;return a}};
+sjcl.codec.utf8String={fromBits:function(a){var b="",c=sjcl.bitArray.bitLength(a),d,e;for(d=0;d<c/8;d++)0===(d&3)&&(e=a[d/4]),b+=String.fromCharCode(e>>>8>>>8>>>8),e<<=8;return decodeURIComponent(escape(b))},toBits:function(a){a=unescape(encodeURIComponent(a));var b=[],c,d=0;for(c=0;c<a.length;c++)d=d<<8|a.charCodeAt(c),3===(c&3)&&(b.push(d),d=0);c&3&&b.push(sjcl.bitArray.partial(8*(c&3),d));return b}};
+sjcl.codec.hex={fromBits:function(a){var b="",c;for(c=0;c<a.length;c++)b+=((a[c]|0)+0xf00000000000).toString(16).substr(4);return b.substr(0,sjcl.bitArray.bitLength(a)/4)},toBits:function(a){var b,c=[],d;a=a.replace(/\s|0x/g,"");d=a.length;a=a+"00000000";for(b=0;b<a.length;b+=8)c.push(parseInt(a.substr(b,8),16)^0);return sjcl.bitArray.clamp(c,4*d)}};
+sjcl.codec.base32={B:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",X:"0123456789ABCDEFGHIJKLMNOPQRSTUV",BITS:32,BASE:5,REMAINING:27,fromBits:function(a,b,c){var d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f="",g=0,h=sjcl.codec.base32.B,k=0,l=sjcl.bitArray.bitLength(a);c&&(h=sjcl.codec.base32.X);for(c=0;f.length*d<l;)f+=h.charAt((k^a[c]>>>g)>>>e),g<d?(k=a[c]<<d-g,g+=e,c++):(k<<=d,g-=d);for(;f.length&7&&!b;)f+="=";return f},toBits:function(a,b){a=a.replace(/\s|=/g,"").toUpperCase();var c=sjcl.codec.base32.BITS,
+d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f=[],g,h=0,k=sjcl.codec.base32.B,l=0,n,m="base32";b&&(k=sjcl.codec.base32.X,m="base32hex");for(g=0;g<a.length;g++){n=k.indexOf(a.charAt(g));if(0>n){if(!b)try{return sjcl.codec.base32hex.toBits(a)}catch(p){}throw new sjcl.exception.invalid("this isn't "+m+"!");}h>e?(h-=e,f.push(l^n>>>h),l=n<<c-h):(h+=d,l^=n<<c-h)}h&56&&f.push(sjcl.bitArray.partial(h&56,l,1));return f}};
+sjcl.codec.base32hex={fromBits:function(a,b){return sjcl.codec.base32.fromBits(a,b,1)},toBits:function(a){return sjcl.codec.base32.toBits(a,1)}};
+sjcl.codec.base64={B:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",fromBits:function(a,b,c){var d="",e=0,f=sjcl.codec.base64.B,g=0,h=sjcl.bitArray.bitLength(a);c&&(f=f.substr(0,62)+"-_");for(c=0;6*d.length<h;)d+=f.charAt((g^a[c]>>>e)>>>26),6>e?(g=a[c]<<6-e,e+=26,c++):(g<<=6,e-=6);for(;d.length&3&&!b;)d+="=";return d},toBits:function(a,b){a=a.replace(/\s|=/g,"");var c=[],d,e=0,f=sjcl.codec.base64.B,g=0,h;b&&(f=f.substr(0,62)+"-_");for(d=0;d<a.length;d++){h=f.indexOf(a.charAt(d));
+if(0>h)throw new sjcl.exception.invalid("this isn't base64!");26<e?(e-=26,c.push(g^h>>>e),g=h<<32-e):(e+=6,g^=h<<32-e)}e&56&&c.push(sjcl.bitArray.partial(e&56,g,1));return c}};sjcl.codec.base64url={fromBits:function(a){return sjcl.codec.base64.fromBits(a,1,1)},toBits:function(a){return sjcl.codec.base64.toBits(a,1)}};sjcl.hash.sha256=function(a){this.b[0]||this.O();a?(this.F=a.F.slice(0),this.A=a.A.slice(0),this.l=a.l):this.reset()};sjcl.hash.sha256.hash=function(a){return(new sjcl.hash.sha256).update(a).finalize()};
+sjcl.hash.sha256.prototype={blockSize:512,reset:function(){this.F=this.Y.slice(0);this.A=[];this.l=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.A=sjcl.bitArray.concat(this.A,a);b=this.l;a=this.l=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=512+b-(512+b&0x1ff);b<=a;b+=512)u(this,d.subarray(16*e,
+16*(e+1))),e+=1;c.splice(0,16*e)}else for(b=512+b-(512+b&0x1ff);b<=a;b+=512)u(this,c.splice(0,16));return this},finalize:function(){var a,b=this.A,c=this.F,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+2;a&15;a++)b.push(0);b.push(Math.floor(this.l/0x100000000));for(b.push(this.l|0);b.length;)u(this,b.splice(0,16));this.reset();return c},Y:[],b:[],O:function(){function a(a){return 0x100000000*(a-Math.floor(a))|0}for(var b=0,c=2,d,e;64>b;c++){e=!0;for(d=2;d*d<=c;d++)if(0===c%d){e=
+!1;break}e&&(8>b&&(this.Y[b]=a(Math.pow(c,.5))),this.b[b]=a(Math.pow(c,1/3)),b++)}}};
+function u(a,b){var c,d,e,f=a.F,g=a.b,h=f[0],k=f[1],l=f[2],n=f[3],m=f[4],p=f[5],r=f[6],q=f[7];for(c=0;64>c;c++)16>c?d=b[c]:(d=b[c+1&15],e=b[c+14&15],d=b[c&15]=(d>>>7^d>>>18^d>>>3^d<<25^d<<14)+(e>>>17^e>>>19^e>>>10^e<<15^e<<13)+b[c&15]+b[c+9&15]|0),d=d+q+(m>>>6^m>>>11^m>>>25^m<<26^m<<21^m<<7)+(r^m&(p^r))+g[c],q=r,r=p,p=m,m=n+d|0,n=l,l=k,k=h,h=d+(k&l^n&(k^l))+(k>>>2^k>>>13^k>>>22^k<<30^k<<19^k<<10)|0;f[0]=f[0]+h|0;f[1]=f[1]+k|0;f[2]=f[2]+l|0;f[3]=f[3]+n|0;f[4]=f[4]+m|0;f[5]=f[5]+p|0;f[6]=f[6]+r|0;f[7]=
+f[7]+q|0}
+sjcl.mode.ccm={name:"ccm",G:[],listenProgress:function(a){sjcl.mode.ccm.G.push(a)},unListenProgress:function(a){a=sjcl.mode.ccm.G.indexOf(a);-1<a&&sjcl.mode.ccm.G.splice(a,1)},fa:function(a){var b=sjcl.mode.ccm.G.slice(),c;for(c=0;c<b.length;c+=1)b[c](a)},encrypt:function(a,b,c,d,e){var f,g=b.slice(0),h=sjcl.bitArray,k=h.bitLength(c)/8,l=h.bitLength(g)/8;e=e||64;d=d||[];if(7>k)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(f=2;4>f&&l>>>8*f;f++);f<15-k&&(f=15-k);c=h.clamp(c,
+8*(15-f));b=sjcl.mode.ccm.V(a,b,c,d,e,f);g=sjcl.mode.ccm.C(a,g,c,b,e,f);return h.concat(g.data,g.tag)},decrypt:function(a,b,c,d,e){e=e||64;d=d||[];var f=sjcl.bitArray,g=f.bitLength(c)/8,h=f.bitLength(b),k=f.clamp(b,h-e),l=f.bitSlice(b,h-e),h=(h-e)/8;if(7>g)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(b=2;4>b&&h>>>8*b;b++);b<15-g&&(b=15-g);c=f.clamp(c,8*(15-b));k=sjcl.mode.ccm.C(a,k,c,l,e,b);a=sjcl.mode.ccm.V(a,k.data,c,d,e,b);if(!f.equal(k.tag,a))throw new sjcl.exception.corrupt("ccm: tag doesn't match");
+return k.data},na:function(a,b,c,d,e,f){var g=[],h=sjcl.bitArray,k=h.i;d=[h.partial(8,(b.length?64:0)|d-2<<2|f-1)];d=h.concat(d,c);d[3]|=e;d=a.encrypt(d);if(b.length)for(c=h.bitLength(b)/8,65279>=c?g=[h.partial(16,c)]:0xffffffff>=c&&(g=h.concat([h.partial(16,65534)],[c])),g=h.concat(g,b),b=0;b<g.length;b+=4)d=a.encrypt(k(d,g.slice(b,b+4).concat([0,0,0])));return d},V:function(a,b,c,d,e,f){var g=sjcl.bitArray,h=g.i;e/=8;if(e%2||4>e||16<e)throw new sjcl.exception.invalid("ccm: invalid tag length");
+if(0xffffffff<d.length||0xffffffff<b.length)throw new sjcl.exception.bug("ccm: can't deal with 4GiB or more data");c=sjcl.mode.ccm.na(a,d,c,e,g.bitLength(b)/8,f);for(d=0;d<b.length;d+=4)c=a.encrypt(h(c,b.slice(d,d+4).concat([0,0,0])));return g.clamp(c,8*e)},C:function(a,b,c,d,e,f){var g,h=sjcl.bitArray;g=h.i;var k=b.length,l=h.bitLength(b),n=k/50,m=n;c=h.concat([h.partial(8,f-1)],c).concat([0,0,0]).slice(0,4);d=h.bitSlice(g(d,a.encrypt(c)),0,e);if(!k)return{tag:d,data:[]};for(g=0;g<k;g+=4)g>n&&(sjcl.mode.ccm.fa(g/
+k),n+=m),c[3]++,e=a.encrypt(c),b[g]^=e[0],b[g+1]^=e[1],b[g+2]^=e[2],b[g+3]^=e[3];return{tag:d,data:h.clamp(b,l)}}};
+sjcl.mode.ocb2={name:"ocb2",encrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");var g,h=sjcl.mode.ocb2.S,k=sjcl.bitArray,l=k.i,n=[0,0,0,0];c=h(a.encrypt(c));var m,p=[];d=d||[];e=e||64;for(g=0;g+4<b.length;g+=4)m=b.slice(g,g+4),n=l(n,m),p=p.concat(l(c,a.encrypt(l(c,m)))),c=h(c);m=b.slice(g);b=k.bitLength(m);g=a.encrypt(l(c,[0,0,0,b]));m=k.clamp(l(m.concat([0,0,0]),g),b);n=l(n,l(m.concat([0,0,0]),g));n=a.encrypt(l(n,l(c,h(c))));
+d.length&&(n=l(n,f?d:sjcl.mode.ocb2.pmac(a,d)));return p.concat(k.concat(m,k.clamp(n,e)))},decrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");e=e||64;var g=sjcl.mode.ocb2.S,h=sjcl.bitArray,k=h.i,l=[0,0,0,0],n=g(a.encrypt(c)),m,p,r=sjcl.bitArray.bitLength(b)-e,q=[];d=d||[];for(c=0;c+4<r/32;c+=4)m=k(n,a.decrypt(k(n,b.slice(c,c+4)))),l=k(l,m),q=q.concat(m),n=g(n);p=r-32*c;m=a.encrypt(k(n,[0,0,0,p]));m=k(m,h.clamp(b.slice(c),p).concat([0,
+0,0]));l=k(l,m);l=a.encrypt(k(l,k(n,g(n))));d.length&&(l=k(l,f?d:sjcl.mode.ocb2.pmac(a,d)));if(!h.equal(h.clamp(l,e),h.bitSlice(b,r)))throw new sjcl.exception.corrupt("ocb: tag doesn't match");return q.concat(h.clamp(m,p))},pmac:function(a,b){var c,d=sjcl.mode.ocb2.S,e=sjcl.bitArray,f=e.i,g=[0,0,0,0],h=a.encrypt([0,0,0,0]),h=f(h,d(d(h)));for(c=0;c+4<b.length;c+=4)h=d(h),g=f(g,a.encrypt(f(h,b.slice(c,c+4))));c=b.slice(c);128>e.bitLength(c)&&(h=f(h,d(h)),c=e.concat(c,[-2147483648,0,0,0]));g=f(g,c);
+return a.encrypt(f(d(f(h,d(h))),g))},S:function(a){return[a[0]<<1^a[1]>>>31,a[1]<<1^a[2]>>>31,a[2]<<1^a[3]>>>31,a[3]<<1^135*(a[0]>>>31)]}};
+sjcl.mode.gcm={name:"gcm",encrypt:function(a,b,c,d,e){var f=b.slice(0);b=sjcl.bitArray;d=d||[];a=sjcl.mode.gcm.C(!0,a,f,d,c,e||128);return b.concat(a.data,a.tag)},decrypt:function(a,b,c,d,e){var f=b.slice(0),g=sjcl.bitArray,h=g.bitLength(f);e=e||128;d=d||[];e<=h?(b=g.bitSlice(f,h-e),f=g.bitSlice(f,0,h-e)):(b=f,f=[]);a=sjcl.mode.gcm.C(!1,a,f,d,c,e);if(!g.equal(a.tag,b))throw new sjcl.exception.corrupt("gcm: tag doesn't match");return a.data},ka:function(a,b){var c,d,e,f,g,h=sjcl.bitArray.i;e=[0,0,
+0,0];f=b.slice(0);for(c=0;128>c;c++){(d=0!==(a[Math.floor(c/32)]&1<<31-c%32))&&(e=h(e,f));g=0!==(f[3]&1);for(d=3;0<d;d--)f[d]=f[d]>>>1|(f[d-1]&1)<<31;f[0]>>>=1;g&&(f[0]^=-0x1f000000)}return e},j:function(a,b,c){var d,e=c.length;b=b.slice(0);for(d=0;d<e;d+=4)b[0]^=0xffffffff&c[d],b[1]^=0xffffffff&c[d+1],b[2]^=0xffffffff&c[d+2],b[3]^=0xffffffff&c[d+3],b=sjcl.mode.gcm.ka(b,a);return b},C:function(a,b,c,d,e,f){var g,h,k,l,n,m,p,r,q=sjcl.bitArray;m=c.length;p=q.bitLength(c);r=q.bitLength(d);h=q.bitLength(e);
+g=b.encrypt([0,0,0,0]);96===h?(e=e.slice(0),e=q.concat(e,[1])):(e=sjcl.mode.gcm.j(g,[0,0,0,0],e),e=sjcl.mode.gcm.j(g,e,[0,0,Math.floor(h/0x100000000),h&0xffffffff]));h=sjcl.mode.gcm.j(g,[0,0,0,0],d);n=e.slice(0);d=h.slice(0);a||(d=sjcl.mode.gcm.j(g,h,c));for(l=0;l<m;l+=4)n[3]++,k=b.encrypt(n),c[l]^=k[0],c[l+1]^=k[1],c[l+2]^=k[2],c[l+3]^=k[3];c=q.clamp(c,p);a&&(d=sjcl.mode.gcm.j(g,h,c));a=[Math.floor(r/0x100000000),r&0xffffffff,Math.floor(p/0x100000000),p&0xffffffff];d=sjcl.mode.gcm.j(g,d,a);k=b.encrypt(e);
+d[0]^=k[0];d[1]^=k[1];d[2]^=k[2];d[3]^=k[3];return{tag:q.bitSlice(d,0,f),data:c}}};sjcl.misc.hmac=function(a,b){this.W=b=b||sjcl.hash.sha256;var c=[[],[]],d,e=b.prototype.blockSize/32;this.w=[new b,new b];a.length>e&&(a=b.hash(a));for(d=0;d<e;d++)c[0][d]=a[d]^909522486,c[1][d]=a[d]^1549556828;this.w[0].update(c[0]);this.w[1].update(c[1]);this.R=new b(this.w[0])};
+sjcl.misc.hmac.prototype.encrypt=sjcl.misc.hmac.prototype.mac=function(a){if(this.aa)throw new sjcl.exception.invalid("encrypt on already updated hmac called!");this.update(a);return this.digest(a)};sjcl.misc.hmac.prototype.reset=function(){this.R=new this.W(this.w[0]);this.aa=!1};sjcl.misc.hmac.prototype.update=function(a){this.aa=!0;this.R.update(a)};sjcl.misc.hmac.prototype.digest=function(){var a=this.R.finalize(),a=(new this.W(this.w[1])).update(a).finalize();this.reset();return a};
+sjcl.misc.pbkdf2=function(a,b,c,d,e){c=c||1E4;if(0>d||0>c)throw new sjcl.exception.invalid("invalid params to pbkdf2");"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));e=e||sjcl.misc.hmac;a=new e(a);var f,g,h,k,l=[],n=sjcl.bitArray;for(k=1;32*l.length<(d||1);k++){e=f=a.encrypt(n.concat(b,[k]));for(g=1;g<c;g++)for(f=a.encrypt(f),h=0;h<f.length;h++)e[h]^=f[h];l=l.concat(e)}d&&(l=n.clamp(l,d));return l};
+sjcl.prng=function(a){this.c=[new sjcl.hash.sha256];this.m=[0];this.P=0;this.H={};this.N=0;this.U={};this.Z=this.f=this.o=this.ha=0;this.b=[0,0,0,0,0,0,0,0];this.h=[0,0,0,0];this.L=void 0;this.M=a;this.D=!1;this.K={progress:{},seeded:{}};this.u=this.ga=0;this.I=1;this.J=2;this.ca=0x10000;this.T=[0,48,64,96,128,192,0x100,384,512,768,1024];this.da=3E4;this.ba=80};
+sjcl.prng.prototype={randomWords:function(a,b){var c=[],d;d=this.isReady(b);var e;if(d===this.u)throw new sjcl.exception.notReady("generator isn't seeded");if(d&this.J){d=!(d&this.I);e=[];var f=0,g;this.Z=e[0]=(new Date).valueOf()+this.da;for(g=0;16>g;g++)e.push(0x100000000*Math.random()|0);for(g=0;g<this.c.length&&(e=e.concat(this.c[g].finalize()),f+=this.m[g],this.m[g]=0,d||!(this.P&1<<g));g++);this.P>=1<<this.c.length&&(this.c.push(new sjcl.hash.sha256),this.m.push(0));this.f-=f;f>this.o&&(this.o=
+f);this.P++;this.b=sjcl.hash.sha256.hash(this.b.concat(e));this.L=new sjcl.cipher.aes(this.b);for(d=0;4>d&&(this.h[d]=this.h[d]+1|0,!this.h[d]);d++);}for(d=0;d<a;d+=4)0===(d+1)%this.ca&&y(this),e=z(this),c.push(e[0],e[1],e[2],e[3]);y(this);return c.slice(0,a)},setDefaultParanoia:function(a,b){if(0===a&&"Setting paranoia=0 will ruin your security; use it only for testing"!==b)throw new sjcl.exception.invalid("Setting paranoia=0 will ruin your security; use it only for testing");this.M=a},addEntropy:function(a,
+b,c){c=c||"user";var d,e,f=(new Date).valueOf(),g=this.H[c],h=this.isReady(),k=0;d=this.U[c];void 0===d&&(d=this.U[c]=this.ha++);void 0===g&&(g=this.H[c]=0);this.H[c]=(this.H[c]+1)%this.c.length;switch(typeof a){case "number":void 0===b&&(b=1);this.c[g].update([d,this.N++,1,b,f,1,a|0]);break;case "object":c=Object.prototype.toString.call(a);if("[object Uint32Array]"===c){e=[];for(c=0;c<a.length;c++)e.push(a[c]);a=e}else for("[object Array]"!==c&&(k=1),c=0;c<a.length&&!k;c++)"number"!==typeof a[c]&&
+(k=1);if(!k){if(void 0===b)for(c=b=0;c<a.length;c++)for(e=a[c];0<e;)b++,e=e>>>1;this.c[g].update([d,this.N++,2,b,f,a.length].concat(a))}break;case "string":void 0===b&&(b=a.length);this.c[g].update([d,this.N++,3,b,f,a.length]);this.c[g].update(a);break;default:k=1}if(k)throw new sjcl.exception.bug("random: addEntropy only supports number, array of numbers or string");this.m[g]+=b;this.f+=b;h===this.u&&(this.isReady()!==this.u&&A("seeded",Math.max(this.o,this.f)),A("progress",this.getProgress()))},
+isReady:function(a){a=this.T[void 0!==a?a:this.M];return this.o&&this.o>=a?this.m[0]>this.ba&&(new Date).valueOf()>this.Z?this.J|this.I:this.I:this.f>=a?this.J|this.u:this.u},getProgress:function(a){a=this.T[a?a:this.M];return this.o>=a?1:this.f>a?1:this.f/a},startCollectors:function(){if(!this.D){this.a={loadTimeCollector:B(this,this.ma),mouseCollector:B(this,this.oa),keyboardCollector:B(this,this.la),accelerometerCollector:B(this,this.ea),touchCollector:B(this,this.qa)};if(window.addEventListener)window.addEventListener("load",
+this.a.loadTimeCollector,!1),window.addEventListener("mousemove",this.a.mouseCollector,!1),window.addEventListener("keypress",this.a.keyboardCollector,!1),window.addEventListener("devicemotion",this.a.accelerometerCollector,!1),window.addEventListener("touchmove",this.a.touchCollector,!1);else if(document.attachEvent)document.attachEvent("onload",this.a.loadTimeCollector),document.attachEvent("onmousemove",this.a.mouseCollector),document.attachEvent("keypress",this.a.keyboardCollector);else throw new sjcl.exception.bug("can't attach event");
+this.D=!0}},stopCollectors:function(){this.D&&(window.removeEventListener?(window.removeEventListener("load",this.a.loadTimeCollector,!1),window.removeEventListener("mousemove",this.a.mouseCollector,!1),window.removeEventListener("keypress",this.a.keyboardCollector,!1),window.removeEventListener("devicemotion",this.a.accelerometerCollector,!1),window.removeEventListener("touchmove",this.a.touchCollector,!1)):document.detachEvent&&(document.detachEvent("onload",this.a.loadTimeCollector),document.detachEvent("onmousemove",
+this.a.mouseCollector),document.detachEvent("keypress",this.a.keyboardCollector)),this.D=!1)},addEventListener:function(a,b){this.K[a][this.ga++]=b},removeEventListener:function(a,b){var c,d,e=this.K[a],f=[];for(d in e)e.hasOwnProperty(d)&&e[d]===b&&f.push(d);for(c=0;c<f.length;c++)d=f[c],delete e[d]},la:function(){C(this,1)},oa:function(a){var b,c;try{b=a.x||a.clientX||a.offsetX||0,c=a.y||a.clientY||a.offsetY||0}catch(d){c=b=0}0!=b&&0!=c&&this.addEntropy([b,c],2,"mouse");C(this,0)},qa:function(a){a=
+a.touches[0]||a.changedTouches[0];this.addEntropy([a.pageX||a.clientX,a.pageY||a.clientY],1,"touch");C(this,0)},ma:function(){C(this,2)},ea:function(a){a=a.accelerationIncludingGravity.x||a.accelerationIncludingGravity.y||a.accelerationIncludingGravity.z;if(window.orientation){var b=window.orientation;"number"===typeof b&&this.addEntropy(b,1,"accelerometer")}a&&this.addEntropy(a,2,"accelerometer");C(this,0)}};
+function A(a,b){var c,d=sjcl.random.K[a],e=[];for(c in d)d.hasOwnProperty(c)&&e.push(d[c]);for(c=0;c<e.length;c++)e[c](b)}function C(a,b){"undefined"!==typeof window&&window.performance&&"function"===typeof window.performance.now?a.addEntropy(window.performance.now(),b,"loadtime"):a.addEntropy((new Date).valueOf(),b,"loadtime")}function y(a){a.b=z(a).concat(z(a));a.L=new sjcl.cipher.aes(a.b)}function z(a){for(var b=0;4>b&&(a.h[b]=a.h[b]+1|0,!a.h[b]);b++);return a.L.encrypt(a.h)}
+function B(a,b){return function(){b.apply(a,arguments)}}sjcl.random=new sjcl.prng(6);
+a:try{var D,E,F,G;if(G="undefined"!==typeof module&&module.exports){var H;try{H=null}catch(a){H=null}G=E=H}if(G&&E.randomBytes)D=E.randomBytes(128),D=new Uint32Array((new Uint8Array(D)).buffer),sjcl.random.addEntropy(D,1024,"crypto['randomBytes']");else if("undefined"!==typeof window&&"undefined"!==typeof Uint32Array){F=new Uint32Array(32);if(window.crypto&&window.crypto.getRandomValues)window.crypto.getRandomValues(F);else if(window.msCrypto&&window.msCrypto.getRandomValues)window.msCrypto.getRandomValues(F);
+else break a;sjcl.random.addEntropy(F,1024,"crypto['getRandomValues']")}}catch(a){"undefined"!==typeof window&&window.console&&(console.log("There was an error collecting entropy from the browser:"),console.log(a))}
+sjcl.json={defaults:{v:1,iter:1E4,ks:128,ts:64,mode:"ccm",adata:"",cipher:"aes"},ja:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json,f=e.g({iv:sjcl.random.randomWords(4,0)},e.defaults),g;e.g(f,c);c=f.adata;"string"===typeof f.salt&&(f.salt=sjcl.codec.base64.toBits(f.salt));"string"===typeof f.iv&&(f.iv=sjcl.codec.base64.toBits(f.iv));if(!sjcl.mode[f.mode]||!sjcl.cipher[f.cipher]||"string"===typeof a&&100>=f.iter||64!==f.ts&&96!==f.ts&&128!==f.ts||128!==f.ks&&192!==f.ks&&0x100!==f.ks||2>f.iv.length||
+4<f.iv.length)throw new sjcl.exception.invalid("json encrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,f),a=g.key.slice(0,f.ks/32),f.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.publicKey&&(g=a.kem(),f.kemtag=g.tag,a=g.key.slice(0,f.ks/32));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));"string"===typeof c&&(f.adata=c=sjcl.codec.utf8String.toBits(c));g=new sjcl.cipher[f.cipher](a);e.g(d,f);d.key=a;f.ct="ccm"===f.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&
+b instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.encrypt(g,b,f.iv,c,f.ts):sjcl.mode[f.mode].encrypt(g,b,f.iv,c,f.ts);return f},encrypt:function(a,b,c,d){var e=sjcl.json,f=e.ja.apply(e,arguments);return e.encode(f)},ia:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json;b=e.g(e.g(e.g({},e.defaults),b),c,!0);var f,g;f=b.adata;"string"===typeof b.salt&&(b.salt=sjcl.codec.base64.toBits(b.salt));"string"===typeof b.iv&&(b.iv=sjcl.codec.base64.toBits(b.iv));if(!sjcl.mode[b.mode]||!sjcl.cipher[b.cipher]||"string"===
+typeof a&&100>=b.iter||64!==b.ts&&96!==b.ts&&128!==b.ts||128!==b.ks&&192!==b.ks&&0x100!==b.ks||!b.iv||2>b.iv.length||4<b.iv.length)throw new sjcl.exception.invalid("json decrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,b),a=g.key.slice(0,b.ks/32),b.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.secretKey&&(a=a.unkem(sjcl.codec.base64.toBits(b.kemtag)).slice(0,b.ks/32));"string"===typeof f&&(f=sjcl.codec.utf8String.toBits(f));g=new sjcl.cipher[b.cipher](a);f="ccm"===
+b.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&b.ct instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.decrypt(g,b.ct,b.iv,b.tag,f,b.ts):sjcl.mode[b.mode].decrypt(g,b.ct,b.iv,f,b.ts);e.g(d,b);d.key=a;return 1===c.raw?f:sjcl.codec.utf8String.fromBits(f)},decrypt:function(a,b,c,d){var e=sjcl.json;return e.ia(a,e.decode(b),c,d)},encode:function(a){var b,c="{",d="";for(b in a)if(a.hasOwnProperty(b)){if(!b.match(/^[a-z0-9]+$/i))throw new sjcl.exception.invalid("json encode: invalid property name");c+=d+'"'+
+b+'":';d=",";switch(typeof a[b]){case "number":case "boolean":c+=a[b];break;case "string":c+='"'+escape(a[b])+'"';break;case "object":c+='"'+sjcl.codec.base64.fromBits(a[b],0)+'"';break;default:throw new sjcl.exception.bug("json encode: unsupported type");}}return c+"}"},decode:function(a){a=a.replace(/\s/g,"");if(!a.match(/^\{.*\}$/))throw new sjcl.exception.invalid("json decode: this isn't json!");a=a.replace(/^\{|\}$/g,"").split(/,/);var b={},c,d;for(c=0;c<a.length;c++){if(!(d=a[c].match(/^\s*(?:(["']?)([a-z][a-z0-9]*)\1)\s*:\s*(?:(-?\d+)|"([a-z0-9+\/%*_.@=\-]*)"|(true|false))$/i)))throw new sjcl.exception.invalid("json decode: this isn't json!");
+null!=d[3]?b[d[2]]=parseInt(d[3],10):null!=d[4]?b[d[2]]=d[2].match(/^(ct|adata|salt|iv)$/)?sjcl.codec.base64.toBits(d[4]):unescape(d[4]):null!=d[5]&&(b[d[2]]="true"===d[5])}return b},g:function(a,b,c){void 0===a&&(a={});if(void 0===b)return a;for(var d in b)if(b.hasOwnProperty(d)){if(c&&void 0!==a[d]&&a[d]!==b[d])throw new sjcl.exception.invalid("required parameter overridden");a[d]=b[d]}return a},sa:function(a,b){var c={},d;for(d in a)a.hasOwnProperty(d)&&a[d]!==b[d]&&(c[d]=a[d]);return c},ra:function(a,
+b){var c={},d;for(d=0;d<b.length;d++)void 0!==a[b[d]]&&(c[b[d]]=a[b[d]]);return c}};sjcl.encrypt=sjcl.json.encrypt;sjcl.decrypt=sjcl.json.decrypt;sjcl.misc.pa={};sjcl.misc.cachedPbkdf2=function(a,b){var c=sjcl.misc.pa,d;b=b||{};d=b.iter||1E3;c=c[a]=c[a]||{};d=c[d]=c[d]||{firstSalt:b.salt&&b.salt.length?b.salt.slice(0):sjcl.random.randomWords(2,0)};c=void 0===b.salt?d.firstSalt:b.salt;d[c]=d[c]||sjcl.misc.pbkdf2(a,c,b.iter);return{key:d[c].slice(0),salt:c.slice(0)}};
+"undefined"!==typeof module&&module.exports&&(module.exports=sjcl);"function"===typeof define&&define([],function(){return sjcl});

+ 146 - 0
client/share/sjclWrapper.js

@@ -0,0 +1,146 @@
+//sjcl.js подправлен (убран лишний require, добавлявший +400kb к bundle) и скопирован локально
+import sjcl from './sjcl';
+
+//везде недоработки...
+
+sjcl.codec.bytes = {
+  fromBits: function(arr) {
+    var out = [], bl = sjcl.bitArray.bitLength(arr), i, tmp;
+    for (i=0; i<bl/8; i++) {
+      if ((i&3) === 0) {
+        tmp = arr[i/4];
+      }
+      out.push(tmp >>> 24);
+      tmp <<= 8;
+    }
+    return out;
+  },
+  toBits: function(bytes) {
+    var out = [], i, tmp=0;
+    for (i=0; i<bytes.length; i++) {
+      tmp = tmp << 8 | bytes[i];
+      if ((i&3) === 3) {
+        out.push(tmp);
+        tmp = 0;
+      }
+    }
+    if (i&3) {
+      out.push(sjcl.bitArray.partial(8*(i&3), tmp));
+    }
+    return out;
+  }
+};
+
+sjcl.json._add = function(target, src, requireSame) {
+    if (target === undefined) { target = {}; }
+    if (src === undefined) { return target; }
+    var i;
+    for (i in src) {
+      if (src.hasOwnProperty(i)) {
+        if (requireSame && target[i] !== undefined && target[i] !== src[i]) {
+          throw new sjcl.exception.invalid("required parameter overridden");
+        }
+        target[i] = src[i];
+      }
+    }
+    return target;
+}
+
+sjcl.encryptArray = function(password, plaintext, params, rp) {
+    params = params || {};
+    rp = rp || {};
+
+    var j = sjcl.json, p = j._add({ iv: sjcl.random.randomWords(4,0) },
+                                  j.defaults), tmp, prp, adata;
+    j._add(p, params);
+    adata = p.adata;
+    if (typeof p.salt === "string") {
+      p.salt = sjcl.codec.base64.toBits(p.salt);
+    }
+    if (typeof p.iv === "string") {
+      p.iv = sjcl.codec.base64.toBits(p.iv);
+    }
+
+    if (!sjcl.mode[p.mode] ||
+        !sjcl.cipher[p.cipher] ||
+        (typeof password === "string" && p.iter <= 100) ||
+        (p.ts !== 64 && p.ts !== 96 && p.ts !== 128) ||
+        (p.ks !== 128 && p.ks !== 192 && p.ks !== 256) ||
+        (p.iv.length < 2 || p.iv.length > 4)) {
+      throw new sjcl.exception.invalid("json encrypt: invalid parameters");
+    }
+
+    if (typeof password === "string") {
+      tmp = sjcl.misc.cachedPbkdf2(password, p);
+      password = tmp.key.slice(0,p.ks/32);
+      p.salt = tmp.salt;
+    } else if (sjcl.ecc && password instanceof sjcl.ecc.elGamal.publicKey) {
+      tmp = password.kem();
+      p.kemtag = tmp.tag;
+      password = tmp.key.slice(0,p.ks/32);
+    }
+    if (typeof plaintext === "string") {
+      plaintext = sjcl.codec.utf8String.toBits(plaintext);
+    }
+    if (typeof adata === "string") {
+      p.adata = adata = sjcl.codec.utf8String.toBits(adata);
+    }
+    prp = new sjcl.cipher[p.cipher](password);
+
+    j._add(rp, p);
+    rp.key = password;
+
+    /* do the encryption */
+    if (p.mode === "ccm" && sjcl.arrayBuffer && sjcl.arrayBuffer.ccm && plaintext instanceof ArrayBuffer) {
+      p.ct = sjcl.arrayBuffer.ccm.encrypt(prp, plaintext, p.iv, adata, p.ts);
+    } else {
+      p.ct = sjcl.mode[p.mode].encrypt(prp, plaintext, p.iv, adata, p.ts);
+    }
+
+    return p;
+}
+
+sjcl.decryptArray = function(password, ciphertext, params) {
+    params = params || {};
+
+    var j = sjcl.json, p = j._add(j._add(j._add({},j.defaults),ciphertext), params, true), ct, tmp, prp, adata=p.adata;
+    if (typeof p.salt === "string") {
+      p.salt = sjcl.codec.base64.toBits(p.salt);
+    }
+    if (typeof p.iv === "string") {
+      p.iv = sjcl.codec.base64.toBits(p.iv);
+    }
+
+    if (!sjcl.mode[p.mode] ||
+        !sjcl.cipher[p.cipher] ||
+        (typeof password === "string" && p.iter <= 100) ||
+        (p.ts !== 64 && p.ts !== 96 && p.ts !== 128) ||
+        (p.ks !== 128 && p.ks !== 192 && p.ks !== 256) ||
+        (!p.iv) ||
+        (p.iv.length < 2 || p.iv.length > 4)) {
+      throw new sjcl.exception.invalid("json decrypt: invalid parameters");
+    }
+
+    if (typeof password === "string") {
+      tmp = sjcl.misc.cachedPbkdf2(password, p);
+      password = tmp.key.slice(0,p.ks/32);
+      p.salt  = tmp.salt;
+    } else if (sjcl.ecc && password instanceof sjcl.ecc.elGamal.secretKey) {
+      password = password.unkem(sjcl.codec.base64.toBits(p.kemtag)).slice(0,p.ks/32);
+    }
+    if (typeof adata === "string") {
+      adata = sjcl.codec.utf8String.toBits(adata);
+    }
+    prp = new sjcl.cipher[p.cipher](password);
+
+    /* do the decryption */
+    if (p.mode === "ccm" && sjcl.arrayBuffer && sjcl.arrayBuffer.ccm && p.ct instanceof ArrayBuffer) {
+      ct = sjcl.arrayBuffer.ccm.decrypt(prp, p.ct, p.iv, p.tag, adata, p.ts);
+    } else {
+      ct = sjcl.mode[p.mode].decrypt(prp, p.ct, p.iv, adata, p.ts);
+    }
+
+    return ct;
+}
+
+export default sjcl;

+ 105 - 11
client/share/utils.js

@@ -1,21 +1,35 @@
+import _ from 'lodash';
+import baseX from 'base-x';
+import PAKO from 'pako';
+import {Buffer} from 'safe-buffer';
+
+export const pako = PAKO;
+
+const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+const bs58 = baseX(BASE58);
+const bs64 = baseX(BASE64);
+
 export function sleep(ms) {
     return new Promise(resolve => setTimeout(resolve, ms));
 }
 
 export function stringToHex(str) {
-    let result = '';
-    for (let i = 0; i < str.length; i++) {
-        result += str.charCodeAt(i).toString(16);
-    }
-    return result;
+    return Buffer.from(str).toString('hex');
 }
 
 export function hexToString(str) {
-    let result = '';
-    for (let i = 0; i < str.length; i += 2) {
-        result += String.fromCharCode(parseInt(str.substr(i, 2), 16));
-    }
-    return result;
+    return Buffer.from(str, 'hex').toString();
+}
+
+export function randomArray(len) {
+    const a = new Uint8Array(len);
+    window.crypto.getRandomValues(a);
+    return a;
+}
+
+export function randomHexString(len) {
+    return Buffer.from(randomArray(len)).toString('hex');
 }
 
 export function formatDate(d, format) {
@@ -62,4 +76,84 @@ export async function copyTextToClipboard(text) {
     }
 
     return result;
-}
+}
+
+export function toBase58(data) {
+    return bs58.encode(Buffer.from(data));
+}
+
+export function fromBase58(data) {
+    return bs58.decode(data);
+}
+
+export function toBase64(data) {
+    return bs64.encode(Buffer.from(data));
+}
+
+export function fromBase64(data) {
+    return bs64.decode(data);
+}
+
+export function getObjDiff(oldObj, newObj) {
+    const result = {__isDiff: true, change: {}, add: {}, del: []};
+
+    for (const key of Object.keys(oldObj)) {
+        if (newObj.hasOwnProperty(key)) {
+            if (!_.isEqual(oldObj[key], newObj[key])) {
+                if (_.isObject(oldObj[key]) && _.isObject(newObj[key])) {
+                    result.change[key] = getObjDiff(oldObj[key], newObj[key]);
+                } else {
+                    result.change[key] = _.cloneDeep(newObj[key]);
+                }
+            }
+        } else {
+            result.del.push(key);
+        }
+    }
+
+    for (const key of Object.keys(newObj)) {
+        if (!oldObj.hasOwnProperty(key)) {
+            result.add[key] = _.cloneDeep(newObj[key]);
+        }
+    }
+
+    return result;
+}
+
+export function isEmptyObjDiff(diff) {
+    return (!_.isObject(diff) || !diff.__isDiff ||
+        (!Object.keys(diff.change).length &&
+            !Object.keys(diff.add).length &&
+            !diff.del.length
+        )
+    );
+}
+
+export function applyObjDiff(obj, diff, isAddChanged) {
+    const result = _.cloneDeep(obj);
+    if (!diff.__isDiff)
+        return result;
+
+    const change = diff.change;
+    for (const key of Object.keys(change)) {
+        if (result.hasOwnProperty(key)) {
+            if (_.isObject(change[key])) {
+                result[key] = applyObjDiff(result[key], change[key], isAddChanged);
+            } else {
+                result[key] = _.cloneDeep(change[key]);
+            }
+        } else if (isAddChanged) {
+            result[key] = _.cloneDeep(change[key]);
+        }
+    }
+
+    for (const key of Object.keys(diff.add)) {
+        result[key] = _.cloneDeep(diff.add[key]);
+    }
+
+    for (const key of diff.del) {
+        delete result[key];
+    }
+
+    return result;
+}

+ 74 - 45
client/store/modules/reader.js

@@ -123,50 +123,51 @@ const webFonts = [
 ];
 
 const settingDefaults = {
-        textColor: '#000000',
-        backgroundColor: '#EBE2C9',
-        wallpaper: '',
-        fontStyle: '',// 'italic'
-        fontWeight: '',// 'bold'
-        fontSize: 20,// px
-        fontName: 'ReaderDefault',
-        webFontName: '',
-        fontVertShift: 0,
-        textVertShift: -20,
-
-        lineInterval: 3,// px, межстрочный интервал
-        textAlignJustify: true,// выравнивание по ширине
-        p: 25,// px, отступ параграфа
-        indentLR: 15,// px, отступ всего текста слева и справа
-        indentTB: 0,// px, отступ всего текста сверху и снизу
-        wordWrap: true,//перенос по слогам
-        keepLastToFirst: true,// перенос последней строки в первую при листании
-
-        showStatusBar: true,
-        statusBarTop: false,// top, bottom
-        statusBarHeight: 19,// px
-        statusBarColorAlpha: 0.4,
-
-        scrollingDelay: 3000,// замедление, ms
-        scrollingType: 'ease-in-out', //linear, ease, ease-in, ease-out, ease-in-out
-
-        pageChangeAnimation: 'blink',// '' - нет, downShift, rightShift, thaw - протаивание, blink - мерцание
-        pageChangeAnimationSpeed: 80, //0-100%
-
-        allowUrlParamBookPos: false,
-        lazyParseEnabled: false,
-        copyFullText: false,
-        showClickMapPage: true,
-        clickControl: true,
-        cutEmptyParagraphs: false,
-        addEmptyParagraphs: 0,
-        blinkCachedLoad: true,
-        showImages: true,
-        showInlineImagesInCenter: true,
-        imageHeightLines: 100,
-        imageFitWidth: true,
-
-        fontShifts: {},
+    textColor: '#000000',
+    backgroundColor: '#EBE2C9',
+    wallpaper: '',
+    fontStyle: '',// 'italic'
+    fontWeight: '',// 'bold'
+    fontSize: 20,// px
+    fontName: 'ReaderDefault',
+    webFontName: '',
+    fontVertShift: 0,
+    textVertShift: -20,
+
+    lineInterval: 3,// px, межстрочный интервал
+    textAlignJustify: true,// выравнивание по ширине
+    p: 25,// px, отступ параграфа
+    indentLR: 15,// px, отступ всего текста слева и справа
+    indentTB: 0,// px, отступ всего текста сверху и снизу
+    wordWrap: true,//перенос по слогам
+    keepLastToFirst: true,// перенос последней строки в первую при листании
+
+    showStatusBar: true,
+    statusBarTop: false,// top, bottom
+    statusBarHeight: 19,// px
+    statusBarColorAlpha: 0.4,
+
+    scrollingDelay: 3000,// замедление, ms
+    scrollingType: 'ease-in-out', //linear, ease, ease-in, ease-out, ease-in-out
+
+    pageChangeAnimation: 'blink',// '' - нет, downShift, rightShift, thaw - протаивание, blink - мерцание
+    pageChangeAnimationSpeed: 80, //0-100%
+
+    allowUrlParamBookPos: false,
+    lazyParseEnabled: false,
+    copyFullText: false,
+    showClickMapPage: true,
+    clickControl: true,
+    cutEmptyParagraphs: false,
+    addEmptyParagraphs: 0,
+    blinkCachedLoad: true,
+    showImages: true,
+    showInlineImagesInCenter: true,
+    imageHeightLines: 100,
+    imageFitWidth: true,
+    showServerStorageMessages: true,
+
+    fontShifts: {},
 };
 
 for (const font of fonts)
@@ -177,7 +178,14 @@ for (const font of webFonts)
 // initial state
 const state = {
     toolBarActive: true,
+    serverSyncEnabled: false,
+    serverStorageKey: '',
+    profiles: {},
+    profilesRev: 0,
+    allowProfilesSave: false,//подстраховка для разработки
+    currentProfile: '',
     settings: Object.assign({}, settingDefaults),
+    settingsRev: {},
 };
 
 // getters
@@ -191,9 +199,30 @@ const mutations = {
     setToolBarActive(state, value) {
         state.toolBarActive = value;
     },
+    setServerSyncEnabled(state, value) {
+        state.serverSyncEnabled = value;
+    },
+    setServerStorageKey(state, value) {
+        state.serverStorageKey = value;
+    },
+    setProfiles(state, value) {
+        state.profiles = value;
+    },
+    setProfilesRev(state, value) {
+        state.profilesRev = value;
+    },
+    setAllowProfilesSave(state, value) {
+        state.allowProfilesSave = value;
+    },
+    setCurrentProfile(state, value) {
+        state.currentProfile = value;
+    },
     setSettings(state, value) {
         state.settings = Object.assign({}, state.settings, value);
-    }
+    },
+    setSettingsRev(state, value) {
+        state.settingsRev = Object.assign({}, state.settingsRev, value);
+    },
 };
 
 export default {

+ 17 - 5
package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "Liberama",
-  "version": "0.5.2",
+  "version": "0.5.6",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -1596,6 +1596,14 @@
         }
       }
     },
+    "base-x": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.5.tgz",
+      "integrity": "sha512-C3picSgzPSLE+jW3tcBzJoGwitOtazb5B+5YmAxZm2ybmTi9LNgAtDO/jjVEBZwHoXmDBZ9m/IELj3elJVRBcA==",
+      "requires": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "base64-js": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
@@ -7144,10 +7152,9 @@
       "dev": true
     },
     "pako": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.7.tgz",
-      "integrity": "sha512-3HNK5tW4x8o5mO8RuHZp3Ydw9icZXx0RANAOMzlMzx7LVXhMJ4mo3MOBpzyd7r/+RUu8BmndP47LXT+vzjtWcQ==",
-      "dev": true
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz",
+      "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw=="
     },
     "parallel-transform": {
       "version": "1.1.0",
@@ -10077,6 +10084,11 @@
         }
       }
     },
+    "sjcl": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz",
+      "integrity": "sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ=="
+    },
     "slash": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",

+ 5 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "Liberama",
-  "version": "0.5.6",
+  "version": "0.6.0",
   "engines": {
     "node": ">=10.0.0"
   },
@@ -56,6 +56,7 @@
   },
   "dependencies": {
     "axios": "^0.18.0",
+    "base-x": "^3.0.5",
     "chardet": "^0.7.0",
     "compression": "^1.7.3",
     "element-ui": "^2.4.11",
@@ -70,7 +71,10 @@
     "lodash": "^4.17.11",
     "minimist": "^1.2.0",
     "multer": "^1.4.1",
+    "pako": "^1.0.10",
     "path-browserify": "^1.0.0",
+    "safe-buffer": "^5.1.2",
+    "sjcl": "^1.0.8",
     "sql-template-strings": "^2.2.2",
     "sqlite": "^3.0.0",
     "tar-fs": "^2.0.0",

+ 13 - 1
server/config/base.js

@@ -14,7 +14,6 @@ module.exports = {
     logDir: `${dataDir}/log`,
     publicDir: `${execDir}/public`,
     uploadDir: `${execDir}/public/upload`,
-    dbFileName: 'db.sqlite',
     loggingEnabled: true,
 
     maxUploadFileSize: 50*1024*1024,//50Мб
@@ -23,6 +22,19 @@ module.exports = {
 
     useExternalBookConverter: false,
 
+    db: [
+        {
+            poolName: 'app',
+            connCount: 20,
+            fileName: 'app.sqlite',
+        },
+        {
+            poolName: 'readerStorage',
+            connCount: 20,
+            fileName: 'reader-storage.sqlite',            
+        }
+    ],
+
     servers: [
         {
             serverName: '1',

+ 1 - 2
server/controllers/BaseController.js

@@ -1,6 +1,5 @@
 class BaseController {
-    constructor(connPool, config) {
-        this.connPool = connPool;
+    constructor(config) {
         this.config = config;
     }
 }

+ 23 - 6
server/controllers/ReaderController.js

@@ -1,12 +1,11 @@
 const BaseController = require('./BaseController');
-const ReaderWorker =  require('../core/ReaderWorker');
-const workerState =  require('../core/workerState');
-//const log = require('../core/getLogger').getLog();
-//const _ = require('lodash');
+const ReaderWorker = require('../core/ReaderWorker');
+const readerStorage = require('../core/readerStorage');
+const workerState = require('../core/workerState');
 
 class ReaderController extends BaseController {
-    constructor(connPool, config) {
-        super(connPool, config);
+    constructor(config) {
+        super(config);
         this.readerWorker = new ReaderWorker(config);
     }
 
@@ -27,6 +26,24 @@ class ReaderController extends BaseController {
         return false;
     }
 
+    async storage(req, res) {
+        const request = req.body;
+        let error = '';
+        try {
+            if (!request.action) 
+                throw new Error(`key 'action' is empty`);
+            if (!request.items || Array.isArray(request.data)) 
+                throw new Error(`key 'items' is empty`);
+
+            return await readerStorage.doAction(request);
+        } catch (e) {
+            error = e.message;
+        }
+        //error
+        res.status(500).send({error});
+        return false;
+    }
+
     async uploadFile(req, res) {
         const file = req.file;
         let error = '';

+ 5 - 0
server/core/BookConverter/ConvertBase.js

@@ -1,6 +1,7 @@
 const fs = require('fs-extra');
 const iconv = require('iconv-lite');
 const chardet = require('chardet');
+const he = require('he');
 
 const textUtils = require('./textUtils');
 const utils = require('../utils');
@@ -80,6 +81,10 @@ class ConvertBase {
         return text.replace(/&nbsp;|[\t\n\r]/g, ' ');
     }
 
+    escapeEntities(text) {
+        return he.escape(he.decode(text));
+    }
+
     formatFb2(fb2) {
         let out = '<?xml version="1.0" encoding="utf-8"?>';
         out += '<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">';

+ 43 - 2
server/core/BookConverter/ConvertHtml.js

@@ -79,6 +79,8 @@ class ConvertHtml extends ConvertBase {
         const newPara = new Set(['tr', '/table', 'hr', 'br', 'br/', 'li', 'dt', 'dd', 'p', 'title', '/title', 'h1', 'h2', 'h3', '/h1', '/h2', '/h3']);
 
         const onTextNode = (text, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
+            text = this.escapeEntities(text);
+
             if (!cutCounter && !(cutTitle && inTitle)) {
                 let tOpen = (bold ? '<strong>' : '');
                 tOpen += (italic ? '<emphasis>' : '');
@@ -242,11 +244,50 @@ class ConvertHtml extends ConvertBase {
             body.section._a[0] = pars;
         }
 
-        //убираем лишнее
+        //убираем лишнее, делаем валидный fb2, т.к. в рез-те разбиения на параграфы бьются теги
+        bold = false;
+        italic = false;
         pars = body.section._a[0];
-        for (let i = 0; i < pars.length; i++)
+        for (let i = 0; i < pars.length; i++) {
+            if (pars[i]._n != 'p')
+                continue;
+
             pars[i]._t = this.repSpaces(pars[i]._t).trim();
 
+            if (pars[i]._t.indexOf('<') >= 0) {
+                const t = pars[i]._t;
+                let a = [];
+
+                const onTextNode = (text) => {
+                    let tOpen = (bold ? '<strong>' : '');
+                    tOpen += (italic ? '<emphasis>' : '');
+                    let tClose = (italic ? '</emphasis>' : '');
+                    tClose += (bold ? '</strong>' : '');
+
+                    a.push(`${tOpen}${text}${tClose}`);
+                }
+
+                const onStartNode = (tag) => {
+                    if (tag == 'strong')
+                        bold = true;
+                    if (tag == 'emphasis')
+                        italic = true;
+                }
+
+                const onEndNode = (tag) => {
+                    if (tag == 'strong')
+                        bold = false;
+                    if (tag == 'emphasis')
+                        italic = false;
+                }
+
+                sax.parseSync(t, { onStartNode, onEndNode, onTextNode });
+
+                pars[i]._t = '';
+                pars[i]._a = a;
+            }
+        }
+
         return this.formatFb2(fb2);
     }
 

+ 2 - 0
server/core/BookConverter/ConvertSamlib.js

@@ -218,6 +218,8 @@ class ConvertSamlib extends ConvertBase {
             if (!text)
                 return;
 
+            text = this.escapeEntities(text);
+
             switch (path) {
                 case '/html/body/center/h2':
                     titleInfo['book-title'] = text;

+ 2 - 2
server/core/BookConverter/sax.js

@@ -11,7 +11,7 @@ function parseSync(xstr, options) {
 
     let i = 0;
     const len = xstr.length;
-    const progStep = len/10;
+    const progStep = len/20;
     let nextProg = 0;
 
     let cutCounter = 0;
@@ -151,7 +151,7 @@ async function parse(xstr, options) {
 
     let i = 0;
     const len = xstr.length;
-    const progStep = len/10;
+    const progStep = len/20;
     let nextProg = 0;
 
     let cutCounter = 0;

+ 0 - 91
server/core/SqliteConnectionPool.js

@@ -1,91 +0,0 @@
-const utils = require('./utils');
-const sqlite = require('sqlite');
-
-const waitingDelay = 100; //ms
-
-class SqliteConnectionPool {
-    constructor(connCount, config) {
-        this.connCount = connCount;
-        this.config = config;
-    }
-
-    async init() {
-        const dbFileName = this.config.dataDir + '/' + this.config.dbFileName;
-
-        this.connections = [];
-        this.taken = new Set();
-        this.freed = new Set();
-
-        for (let i = 0; i < this.connCount; i++) {
-
-            let client = await sqlite.open(dbFileName);
-            client.configure('busyTimeout', 10000); //ms
-
-            client.ret = () => {
-                this.taken.delete(i);
-                this.freed.add(i);
-            };
-
-            this.freed.add(i);
-            this.connections[i] = client;
-        }
-    }
-
-    _setImmediate() {
-        return new Promise((resolve) => {
-            setImmediate(() => {
-                return resolve();
-            });
-        });
-    }
-
-    async get() {
-        if (this.closed)
-            return;
-
-        let freeConnIndex = this.freed.values().next().value;
-        if (freeConnIndex == null) {
-            if (waitingDelay)
-                await utils.sleep(waitingDelay);
-            return await this._setImmediate().then(() => this.get());
-        }
-
-        this.freed.delete(freeConnIndex);
-        this.taken.add(freeConnIndex);
-
-        return this.connections[freeConnIndex];
-    }
-
-    async run(query) {
-        const dbh = await this.get();
-        try {
-            let result = await dbh.run(query);
-            dbh.ret();
-            return result;
-        } catch (e) {
-            dbh.ret();
-            throw e;
-        }
-    }
-
-    async all(query) {
-        const dbh = await this.get();
-        try {
-            let result = await dbh.all(query);
-            dbh.ret();
-            return result;
-        } catch (e) {
-            dbh.ret();
-            throw e;
-        }
-    }
-
-    async close() {
-        for (let i = 0; i < this.connections.length; i++) {
-            await this.connections[i].close();
-        }
-        this.closed = true;
-    }
-}
-
-module.exports = SqliteConnectionPool;

+ 118 - 0
server/core/readerStorage.js

@@ -0,0 +1,118 @@
+const SQL = require('sql-template-strings');
+const _ = require('lodash');
+
+const connManager = require('../db/connManager');
+
+class ReaderStorage {
+    constructor() {
+        this.storagePool = connManager.pool.readerStorage;
+        this.periodicCleanCache(3*3600*1000);//1 раз в 3 часа
+    }
+
+    async doAction(act) {
+        if (!_.isObject(act.items))
+            throw new Error('items is not an object');
+
+        let result = {};
+        switch (act.action) {
+            case 'check':
+                result = await this.checkItems(act.items);
+                break;
+            case 'get':
+                result = await this.getItems(act.items);
+                break;
+            case 'set':
+                result = await this.setItems(act.items, act.force);
+                break;
+            default:
+                throw new Error('Unknown action');
+        }
+
+        return result;
+    }
+
+    async checkItems(items) {
+        let result = {state: 'success', items: {}};
+
+        const dbh = await this.storagePool.get();
+        try {
+            for (const id of Object.keys(items)) {
+                if (this.cache[id]) {
+                    result.items[id] = this.cache[id];
+                } else {
+                    const rows = await dbh.all(SQL`SELECT rev FROM storage WHERE id = ${id}`);
+                    const rev = (rows.length && rows[0].rev ? rows[0].rev : 0);
+                    result.items[id] = {rev};
+                    this.cache[id] = result.items[id];
+                }
+            }
+        } finally {
+            dbh.ret();
+        }
+
+        return result;
+    }
+
+    async getItems(items) {
+        let result = {state: 'success', items: {}};
+
+        const dbh = await this.storagePool.get();
+        try {
+            for (const id of Object.keys(items)) {
+                const rows = await dbh.all(SQL`SELECT rev, data FROM storage WHERE id = ${id}`);                
+                const rev = (rows.length && rows[0].rev ? rows[0].rev : 0);
+                const data = (rows.length && rows[0].data ? rows[0].data : '');
+                result.items[id] = {rev, data};
+            }
+        } finally {
+            dbh.ret();
+        }
+
+        return result;
+    }
+
+    async setItems(items, force) {
+        let check = await this.checkItems(items);
+
+        //сначала проверим совпадение ревизий
+        for (const id of Object.keys(items)) {
+            if (!_.isString(items[id].data))
+                throw new Error('items.data is not a string');
+
+            if (!force && check.items[id].rev + 1 !== items[id].rev)
+                return {state: 'reject', items: check.items};
+        }
+
+        const dbh = await this.storagePool.get();
+        await dbh.run('BEGIN');
+        try {
+            const newRev = {};
+            for (const id of Object.keys(items)) {
+                await dbh.run(SQL`INSERT OR REPLACE INTO storage (id, rev, time, data) VALUES (${id}, ${items[id].rev}, strftime('%s','now'), ${items[id].data})`);
+                newRev[id] = {rev: items[id].rev};
+            }
+            await dbh.run('COMMIT');
+            
+            Object.assign(this.cache, newRev);
+        } catch (e) {
+            await dbh.run('ROLLBACK');
+            throw e;
+        } finally {
+            dbh.ret();
+        }
+
+        return {state: 'success'};
+    }
+
+    periodicCleanCache(timeout) {
+        this.cache = {};
+
+        setTimeout(() => {
+            this.periodicCleanCache(timeout);
+        }, timeout);
+    }
+}
+
+const readerStorage = new ReaderStorage();
+
+module.exports = readerStorage;

+ 188 - 0
server/db/SqliteConnectionPool.js

@@ -0,0 +1,188 @@
+const sqlite = require('sqlite');
+const SQL = require('sql-template-strings');
+
+const utils = require('../core/utils');
+
+const waitingDelay = 100; //ms
+
+class SqliteConnectionPool {
+    constructor() {
+        this.closed = true;
+    }
+
+    async open(connCount, dbFileName) {
+        if (!Number.isInteger(connCount) || connCount <= 0)
+            return;
+        this.connections = [];
+        this.taken = new Set();
+        this.freed = new Set();
+
+        for (let i = 0; i < connCount; i++) {
+            let client = await sqlite.open(dbFileName);
+            client.configure('busyTimeout', 10000); //ms
+
+            client.ret = () => {
+                this.taken.delete(i);
+                this.freed.add(i);
+            };
+
+            this.freed.add(i);
+            this.connections[i] = client;
+        }
+        this.closed = false;
+    }
+
+    _setImmediate() {
+        return new Promise((resolve) => {
+            setImmediate(() => {
+                return resolve();
+            });
+        });
+    }
+
+    async get() {
+        if (this.closed)
+            return;
+
+        let freeConnIndex = this.freed.values().next().value;
+        if (freeConnIndex == null) {
+            if (waitingDelay)
+                await utils.sleep(waitingDelay);
+            return await this._setImmediate().then(() => this.get());
+        }
+
+        this.freed.delete(freeConnIndex);
+        this.taken.add(freeConnIndex);
+
+        return this.connections[freeConnIndex];
+    }
+
+    async run(query) {
+        const dbh = await this.get();
+        try {
+            let result = await dbh.run(query);
+            dbh.ret();
+            return result;
+        } catch (e) {
+            dbh.ret();
+            throw e;
+        }
+    }
+
+    async all(query) {
+        const dbh = await this.get();
+        try {
+            let result = await dbh.all(query);
+            dbh.ret();
+            return result;
+        } catch (e) {
+            dbh.ret();
+            throw e;
+        }
+    }
+
+    async exec(query) {
+        const dbh = await this.get();
+        try {
+            let result = await dbh.exec(query);
+            dbh.ret();
+            return result;
+        } catch (e) {
+            dbh.ret();
+            throw e;
+        }
+    }
+
+    async close() {
+        for (let i = 0; i < this.connections.length; i++) {
+            await this.connections[i].close();
+        }
+        this.closed = true;
+    }
+
+     // Modified from node-sqlite/.../src/Database.js
+    async migrate(migs, table, force) {
+        const migrations = migs.sort((a, b) => Math.sign(a.id - b.id));
+
+        if (!migrations.length) {
+            throw new Error('No migration data');
+        }
+
+        migrations.map(migration => {
+            const data = migration.data;
+            const [up, down] = data.split(/^--\s+?down\b/mi);
+            if (!down) {
+                const message = `The ${migration.filename} file does not contain '-- Down' separator.`;
+                throw new Error(message);
+            } else {
+                /* eslint-disable no-param-reassign */
+                migration.up = up.replace(/^-- .*?$/gm, '').trim();// Remove comments
+                migration.down = down.trim(); // and trim whitespaces
+            }
+        });
+
+        // Create a database table for migrations meta data if it doesn't exist
+        await this.run(`CREATE TABLE IF NOT EXISTS "${table}" (
+    id   INTEGER PRIMARY KEY,
+    name TEXT    NOT NULL,
+    up   TEXT    NOT NULL,
+    down TEXT    NOT NULL
+)`);
+
+        // Get the list of already applied migrations
+        let dbMigrations = await this.all(
+            `SELECT id, name, up, down FROM "${table}" ORDER BY id ASC`,
+        );
+
+        // Undo migrations that exist only in the database but not in migs,
+        // also undo the last migration if the `force` option was set to `last`.
+        const lastMigration = migrations[migrations.length - 1];
+        for (const migration of dbMigrations.slice().sort((a, b) => Math.sign(b.id - a.id))) {
+            if (!migrations.some(x => x.id === migration.id) ||
+                (force === 'last' && migration.id === lastMigration.id)) {
+                const dbh = await this.get();
+                await dbh.run('BEGIN');
+                try {
+                    await dbh.exec(migration.down);
+                    await dbh.run(SQL`DELETE FROM "`.append(table).append(SQL`" WHERE id = ${migration.id}`));
+                    await dbh.run('COMMIT');
+                    dbMigrations = dbMigrations.filter(x => x.id !== migration.id);
+                } catch (err) {
+                    await dbh.run('ROLLBACK');
+                    throw err;
+                } finally {
+                    dbh.ret();
+                }
+            } else {
+                break;
+            }
+        }
+
+        // Apply pending migrations
+        let applied = [];
+        const lastMigrationId = dbMigrations.length ? dbMigrations[dbMigrations.length - 1].id : 0;
+        for (const migration of migrations) {
+            if (migration.id > lastMigrationId) {
+                const dbh = await this.get();
+                await dbh.run('BEGIN');
+                try {
+                    await dbh.exec(migration.up);
+                    await dbh.run(SQL`INSERT INTO "`.append(table).append(
+                        SQL`" (id, name, up, down) VALUES (${migration.id}, ${migration.name}, ${migration.up}, ${migration.down})`)
+                    );
+                    await dbh.run('COMMIT');
+                    applied.push(migration.id);
+                } catch (err) {
+                    await dbh.run('ROLLBACK');
+                    throw err;
+                } finally {
+                    dbh.ret();
+                }
+            }
+        }
+
+        return applied;
+    }
+}
+
+module.exports = SqliteConnectionPool;

+ 50 - 0
server/db/connManager.js

@@ -0,0 +1,50 @@
+const fs = require('fs-extra');
+
+const SqliteConnectionPool = require('./SqliteConnectionPool');
+const log = require('../core/getLogger').getLog();
+
+const migrations = {
+    'app': require('./migrations/app'),
+    'readerStorage': require('./migrations/readerStorage'),
+};
+
+class ConnManager {
+    constructor() {
+        this._pool = {};
+    }
+
+    async init(config) {
+        this.config = config;
+
+        const force = null;//(config.branch == 'development' ? 'last' : null);
+
+        for (const poolConfig of this.config.db) {
+            const dbFileName = this.config.dataDir + '/' + poolConfig.fileName;
+
+            //бэкап
+            await fs.copy(dbFileName, `${dbFileName}.bak`);
+
+            const connPool = new SqliteConnectionPool();
+            await connPool.open(poolConfig.connCount, dbFileName);
+
+            log(`Opened database "${poolConfig.poolName}"`);
+            //миграции
+            const migs = migrations[poolConfig.poolName];
+            if (migs && migs.data.length) {
+                const applied = await connPool.migrate(migs.data, migs.table, force);
+                if (applied.length)
+                    log(`${applied.length} migrations applied to "${poolConfig.poolName}"`);
+            }
+
+            this._pool[poolConfig.poolName] = connPool;
+        }
+    }
+
+    get pool() {
+        return this._pool;
+    }
+}
+
+const connManager = new ConnManager();
+
+module.exports = connManager;

+ 5 - 0
server/db/migrations/app/index.js

@@ -0,0 +1,5 @@
+module.exports = {
+    table: 'migration1',
+    data: [
+    ]
+}

+ 7 - 0
server/db/migrations/readerStorage/001-create.js

@@ -0,0 +1,7 @@
+module.exports = `
+-- Up
+CREATE TABLE storage (id TEXT PRIMARY KEY, rev INTEGER, time INTEGER, data TEXT);
+
+-- Down
+DROP TABLE storage;
+`;

+ 6 - 0
server/db/migrations/readerStorage/index.js

@@ -0,0 +1,6 @@
+module.exports = {
+    table: 'migration1',
+    data: [
+        {id: 1, name: 'create', data: require('./001-create')}
+    ]
+}

+ 3 - 5
server/index.js

@@ -11,7 +11,7 @@ const path = require('path');
 const express = require('express');
 const compression = require('compression');
 
-const SqliteConnectionPool = require('./core/SqliteConnectionPool');
+const connManager = require('./db/connManager');
 
 async function init() {
     await fs.ensureDir(config.dataDir);
@@ -35,9 +35,7 @@ async function main() {
     log('Initializing');
     await init();
 
-    log('Opening database');
-    const connPool = new SqliteConnectionPool(20, config);
-    await connPool.init();
+    await connManager.init(config);
 
     //servers
     for (let server of config.servers) {
@@ -67,7 +65,7 @@ async function main() {
                 }               
             }));
 
-            require('./routes').initRoutes(app, connPool, serverConfig);
+            require('./routes').initRoutes(app, serverConfig);
 
             if (devModule) {
                 devModule.logErrors(app);

+ 5 - 4
server/routes.js

@@ -2,10 +2,10 @@ const c = require('./controllers');
 const utils = require('./core/utils');
 const multer = require('multer');
 
-function initRoutes(app, connPool, config) {
-    const misc = new c.MiscController(connPool, config);
-    const reader = new c.ReaderController(connPool, config);
-    const worker = new c.WorkerController(connPool, config);
+function initRoutes(app, config) {
+    const misc = new c.MiscController(config);
+    const reader = new c.ReaderController(config);
+    const worker = new c.WorkerController(config);
 
     //access
     const [aAll, aNormal, aSite, aReader, aOmnireader] = // eslint-disable-line no-unused-vars
@@ -26,6 +26,7 @@ function initRoutes(app, connPool, config) {
     const routes = [
         ['POST', '/api/config', misc.getConfig.bind(misc), [aAll], {}],
         ['POST', '/api/reader/load-book', reader.loadBook.bind(reader), [aAll], {}],
+        ['POST', '/api/reader/storage', reader.storage.bind(reader), [aAll], {}],
         ['POST', '/api/reader/upload-file', [upload.single('file'), reader.uploadFile.bind(reader)], [aAll], {}],
         ['POST', '/api/worker/get-state', worker.getState.bind(worker), [aAll], {}],
     ];