Browse Source

Merge branch 'release/1.3.2'

Book Pauk 2 years ago
parent
commit
d71c235ebc

+ 7 - 1
README.md

@@ -42,7 +42,7 @@ OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opd
 - фильтр авторов и книг при создании поисковой БД для создания своей коллекции "на лету"
 - подхват изменений .inpx-файла (периодическая проверка), автоматическое пересоздание поисковой БД
 - мощная оптимизация, хорошая скорость поиска
-- релизы под Linux и Windows
+- релизы под Linux, MacOS и Windows
 
 <a id="usage" />
 
@@ -79,8 +79,14 @@ Options:
 ```js
 {
     // пароль для ограничения доступа к веб-интерфейсу сервера
+    // пустое значение - доступ без ограничений
     "accessPassword": "",
 
+    // таймаут автозавершения сессии доступа к веб-интерфейсу (если задан accessPassword),
+    // при неактивности в течение указанного времени (в минутах), пароль будет запрошен заново
+    // 0 - отключить таймаут, время доступа по паролю не ограничено
+    "accessTimeout": 0,
+
     // содержимое кнопки-ссылки "(читать)", если не задано - кнопка "(читать)" не показывается
     // пример: "https://omnireader.ru/#/reader?url=${DOWNLOAD_LINK}"
     // на место ${DOWNLOAD_LINK} будет подставлена ссылка на скачивание файла книги

+ 26 - 5
client/components/Api/Api.vue

@@ -60,10 +60,21 @@ const componentOptions = {
         settings() {
             this.loadSettings();
         },
+        modelValue(newValue) {
+            this.accessGranted = newValue;
+        },
+        accessGranted(newValue) {
+            this.$emit('update:modelValue', newValue);
+        }
     },
 };
 class Api {
     _options = componentOptions;
+    _props = {
+        modelValue: Boolean,
+    };
+    accessGranted = false;
+
     busyDialogVisible = false;
     mainMessage = '';
     jobMessage = '';
@@ -98,10 +109,6 @@ class Api {
         }
     }
 
-    get config() {
-        return this.$store.state.config;
-    }
-
     get settings() {
         return this.$store.state.settings;
     }
@@ -123,7 +130,13 @@ class Api {
             });
 
             if (result && result.value) {
-                const accessToken = utils.toHex(cryptoUtils.sha256(result.value));
+                //получим свежую соль
+                const response = await wsc.message(await wsc.send({}), 10);
+                let salt = '';
+                if (response && response.error == 'need_access_token' && response.salt)
+                    salt = response.salt;
+
+                const accessToken = utils.toHex(cryptoUtils.sha256(result.value + salt));
                 this.commit('setSettings', {accessToken});
             }
         } finally {
@@ -192,10 +205,13 @@ class Api {
                 const response = await wsc.message(await wsc.send(params), timeoutSecs);
 
                 if (response && response.error == 'need_access_token') {
+                    this.accessGranted = false;
                     await this.showPasswordDialog();
                 } else if (response && response.error == 'server_busy') {
+                    this.accessGranted = true;
                     await this.showBusyDialog();
                 } else {
+                    this.accessGranted = true;
                     if (response.error) {
                         throw new Error(response.error);
                     }
@@ -242,6 +258,11 @@ class Api {
     async getConfig() {
         return await this.request({action: 'get-config'});
     }
+
+    async logout() {
+        await this.request({action: 'logout'});
+        await this.request({action: 'test'});
+    }
 }
 
 export default vueComponent(Api);

+ 3 - 2
client/components/App.vue

@@ -1,10 +1,10 @@
 <template>
     <div class="fit row">
-        <Api ref="api" />
+        <Api ref="api" v-model="accessGranted" />
         <Notify ref="notify" />
         <StdDialog ref="stdDialog" />
 
-        <router-view v-slot="{ Component }">
+        <router-view v-if="accessGranted" v-slot="{ Component }">
             <keep-alive>
                 <component :is="Component" class="col" />
             </keep-alive>
@@ -37,6 +37,7 @@ const componentOptions = {
 };
 class App {
     _options = componentOptions;
+    accessGranted = false;
 
     created() {
         this.commit = this.$store.commit;

+ 19 - 1
client/components/Search/AuthorList/AuthorList.vue

@@ -25,7 +25,7 @@
 
                 <div class="q-ml-sm text-bold" style="color: #555">
                     {{ getBookCount(item) }}
-                </div>                    
+                </div>
             </div>
 
             <div v-if="item.bookLoading" class="book-row row items-center">
@@ -54,6 +54,10 @@
                             <div class="clickable2 q-ml-xs q-py-sm text-bold" @click="selectSeries(book.series)">
                                 Серия: {{ book.series }}
                             </div>
+
+                            <div class="q-ml-sm text-bold" style="color: #555">
+                                {{ getSeriesBookCount(book) }}
+                            </div>
                         </div>
 
                         <div v-if="isExpandedSeries(book) && book.seriesBooks">
@@ -184,6 +188,20 @@ class AuthorList extends BaseList {
         return `(${result})`;
     }
 
+    getSeriesBookCount(book) {
+        let result = '';
+        if (!this.showCounts || book.type != 'series')
+            return result;
+
+        let count = book.seriesBooks.length;
+        result = `${count}`;
+        if (book.allBooksLoaded) {
+            result += `/${book.allBooksLoaded.length}`;
+        }
+
+        return `(${result})`;
+    }
+
     async expandAuthor(item) {
         this.$emit('listEvent', {action: 'ignoreScroll'});
 

+ 14 - 0
client/components/Search/Search.vue

@@ -46,6 +46,14 @@
                             </q-tooltip>
                         </template>
                     </DivBtn>
+
+                    <DivBtn v-if="!config.freeAccess" class="q-ml-sm text-white bg-secondary" :size="30" :icon-size="24" :imt="1" icon="la la-sign-out-alt" round @click.stop.prevent="logout">
+                        <template #tooltip>
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                Выход
+                            </q-tooltip>
+                        </template>
+                    </DivBtn>
                 </div>
                 <div class="row q-mx-md q-mb-xs items-center">
                     <DivBtn
@@ -427,6 +435,8 @@ class Search {
 
     mounted() {
         (async() => {
+            await this.api.updateConfig();
+
             //для встраивания в liberama
             window.addEventListener('message', (event) => {
                 if (!_.isObject(event.data) || event.data.from != 'ExternalLibs')
@@ -979,6 +989,10 @@ class Search {
     cloneSearch() {
         window.open(window.location.href, '_blank');
     }
+
+    async logout() {
+        await this.api.logout();
+    }
 }
 
 export default vueComponent(Search);

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "inpx-web",
-  "version": "1.3.1",
+  "version": "1.3.2",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "inpx-web",
-      "version": "1.3.1",
+      "version": "1.3.2",
       "hasInstallScript": true,
       "license": "CC0-1.0",
       "dependencies": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inpx-web",
-  "version": "1.3.1",
+  "version": "1.3.2",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "repository": "bookpauk/inpx-web",

+ 1 - 0
server/config/base.js

@@ -11,6 +11,7 @@ module.exports = {
     execDir,
 
     accessPassword: '',
+    accessTimeout: 0,
     bookReadLink: '',
     loggingEnabled: true,
 

+ 1 - 0
server/config/index.js

@@ -6,6 +6,7 @@ const branchFilename = __dirname + '/application_env';
 
 const propsToSave = [
     'accessPassword',
+    'accessTimeout',
     'bookReadLink',
     'loggingEnabled',
     'dbCacheSize',

+ 35 - 18
server/controllers/WebSocketController.js

@@ -10,12 +10,11 @@ const cleanPeriod = 1*60*1000;//1 минута
 const closeSocketOnIdle = 5*60*1000;//5 минут
 
 class WebSocketController {
-    constructor(wss, config) {
+    constructor(wss, webAccess, config) {
         this.config = config;
         this.isDevelopment = (config.branch == 'development');
-        this.accessToken = '';
-        if (config.accessPassword)
-            this.accessToken = utils.getBufHash(config.accessPassword, 'sha256', 'hex');
+
+        this.webAccess = webAccess;
 
         this.workerState = new WorkerState();
         this.webWorker = new WebWorker(config);
@@ -32,19 +31,25 @@ class WebSocketController {
             });
         });
 
-        setTimeout(() => { this.periodicClean(); }, cleanPeriod);
+        this.periodicClean();//no await
     }
 
-    periodicClean() {
-        try {
-            const now = Date.now();
-            this.wss.clients.forEach((ws) => {
-                if (!ws.lastActivity || now - ws.lastActivity > closeSocketOnIdle - 50) {
-                    ws.terminate();
-                }
-            });
-        } finally {
-            setTimeout(() => { this.periodicClean(); }, cleanPeriod);
+    async periodicClean() {
+        while (1) {//eslint-disable-line no-constant-condition
+            try {
+                const now = Date.now();
+
+                //почистим ws-клиентов
+                this.wss.clients.forEach((ws) => {
+                    if (!ws.lastActivity || now - ws.lastActivity > closeSocketOnIdle - 50) {
+                        ws.terminate();
+                    }
+                });
+            } catch(e) {
+                log(LM_ERR, `WebSocketController.periodicClean error: ${e.message}`);
+            }
+            
+            await utils.sleep(cleanPeriod);
         }
     }
 
@@ -62,14 +67,20 @@ class WebSocketController {
             //pong for WebSocketConnection
             this.send({_rok: 1}, req, ws);
 
-            if (this.accessToken && req.accessToken !== this.accessToken) {
-                await utils.sleep(1000);
-                throw new Error('need_access_token');
+            //access
+            if (!await this.webAccess.hasAccess(req.accessToken)) {
+                await utils.sleep(500);
+                const salt = this.webAccess.newToken();
+                this.send({error: 'need_access_token', salt}, req, ws);
+                return;
             }
 
+            //api
             switch (req.action) {
                 case 'test':
                     await this.test(req, ws); break;
+                case 'logout':
+                    await this.logout(req, ws); break;
                 case 'get-config':
                     await this.getConfig(req, ws); break;
                 case 'get-worker-state':
@@ -120,9 +131,15 @@ class WebSocketController {
         this.send({message: `${this.config.name} project is awesome`}, req, ws);
     }
 
+    async logout(req, ws) {
+        await this.webAccess.deleteAccess(req.accessToken);
+        this.send({success: true}, req, ws);
+    }
+
     async getConfig(req, ws) {
         const config = _.pick(this.config, this.config.webConfigParams);
         config.dbConfig = await this.webWorker.dbConfig();
+        config.freeAccess = this.webAccess.freeAccess;
 
         this.send(config, req, ws);
     }

+ 144 - 0
server/core/WebAccess.js

@@ -0,0 +1,144 @@
+const { JembaDbThread } = require('jembadb');
+const utils = require('../core/utils');
+const log = new (require('../core/AppLogger'))().log;//singleton
+
+const cleanPeriod = 1*60*1000;//1 минута
+const cleanUnusedTokenTimeout = 5*60*1000;//5 минут
+
+class WebAccess {
+    constructor(config) {
+        this.config = config;
+
+        this.freeAccess = (config.accessPassword === '');
+        this.accessTimeout = config.accessTimeout*60*1000;
+        this.accessMap = new Map();
+
+        setTimeout(() => { this.periodicClean(); }, cleanPeriod);
+    }
+
+    async init() {
+        const config = this.config;
+        const dbPath = `${config.dataDir}/web-access`;
+        const db = new JembaDbThread();//в отдельном потоке
+        await db.lock({
+            dbPath,
+            create: true,
+            softLock: true,
+
+            tableDefaults: {
+                cacheSize: config.dbCacheSize,
+            },
+        });
+
+        try {
+            //открываем таблицы
+            await db.openAll();
+        } catch(e) {
+            if (
+                e.message.indexOf('corrupted') >= 0 
+                || e.message.indexOf('Unexpected token') >= 0
+                || e.message.indexOf('invalid stored block lengths') >= 0
+            ) {
+                log(LM_ERR, `DB ${dbPath} corrupted`);
+                log(`Open "${dbPath}" with auto repair`);
+                await db.openAll({autoRepair: true});
+            } else {
+                throw e;
+            }
+        }
+
+        await db.create({table: 'access', quietIfExists: true});
+        //проверим, нужно ли обнулить таблицу access
+        const pass = utils.getBufHash(this.config.accessPassword, 'sha256', 'hex');
+        await db.create({table: 'config', quietIfExists: true});
+        let rows = await db.select({table: 'config', where: `@@id('pass')`});
+
+        if (!rows.length || rows[0].value !== pass) {
+            //пароль сменился в конфиге, обнуляем токены
+            await db.truncate({table: 'access'});
+            await db.insert({table: 'config', replace: true, rows: [{id: 'pass', value: pass}]});
+        }
+
+        //загрузим токены сессий
+        rows = await db.select({table: 'access'});
+        for (const row of rows)
+            this.accessMap.set(row.id, row.value);
+
+        this.db = db;
+    }
+
+    async periodicClean() {
+        while (1) {//eslint-disable-line no-constant-condition
+            try {
+                const now = Date.now();
+
+                //почистим accessMap
+                if (!this.freeAccess) {
+                    for (const [accessToken, accessRec] of this.accessMap) {
+                        if (   !(accessRec.used > 0 || now - accessRec.time < cleanUnusedTokenTimeout)
+                            || !(this.accessTimeout === 0 || now - accessRec.time < this.accessTimeout)
+                            ) {
+                            await this.deleteAccess(accessToken);
+                        } else if (!accessRec.saved) {
+                            await this.saveAccess(accessToken);
+                        }
+                    }
+                }
+
+            } catch(e) {
+                log(LM_ERR, `WebAccess.periodicClean error: ${e.message}`);
+            }
+            
+            await utils.sleep(cleanPeriod);
+        }
+    }
+
+    async hasAccess(accessToken) {
+        if (this.freeAccess)
+            return true;
+
+        const accessRec = this.accessMap.get(accessToken);
+        if (accessRec) {
+            const now = Date.now();
+
+            if (this.accessTimeout === 0 || now - accessRec.time < this.accessTimeout) {
+                accessRec.used++;
+                accessRec.time = now;
+                accessRec.saved = false;
+                if (accessRec.used === 1)
+                    await this.saveAccess(accessToken);
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    async deleteAccess(accessToken) {
+        await this.db.delete({table: 'access', where: `@@id(${this.db.esc(accessToken)})`});
+        this.accessMap.delete(accessToken);
+    }
+
+    async saveAccess(accessToken) {
+        const value = this.accessMap.get(accessToken);
+        if (!value || value.saved)
+            return;
+
+        value.saved = true;
+        await this.db.insert({
+            table: 'access',
+            replace: true,
+            rows: [{id: accessToken, value}]
+        });
+    }
+
+    newToken() {
+        const salt = utils.randomHexString(32);
+        const accessToken = utils.getBufHash(this.config.accessPassword + salt, 'sha256', 'hex');
+        this.accessMap.set(accessToken, {time: Date.now(), used: 0});
+
+        return salt;
+    }
+}
+
+module.exports = WebAccess;

+ 5 - 1
server/index.js

@@ -158,8 +158,12 @@ async function main() {
     opds(app, config);
     initStatic(app, config);
     
+    const WebAccess = require('./core/WebAccess');
+    const webAccess = new WebAccess(config);
+    await webAccess.init();
+
     const { WebSocketController } = require('./controllers');
-    new WebSocketController(wss, config);
+    new WebSocketController(wss, webAccess, config);
 
     if (devModule) {
         devModule.logErrors(app);