diff --git a/README.md b/README.md index 130f0e4..92d5bd7 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opd - фильтр авторов и книг при создании поисковой БД для создания своей коллекции "на лету" - подхват изменений .inpx-файла (периодическая проверка), автоматическое пересоздание поисковой БД - мощная оптимизация, хорошая скорость поиска -- релизы под Linux и Windows +- релизы под Linux, MacOS и Windows @@ -79,8 +79,14 @@ Options: ```js { // пароль для ограничения доступа к веб-интерфейсу сервера + // пустое значение - доступ без ограничений "accessPassword": "", + // таймаут автозавершения сессии доступа к веб-интерфейсу (если задан accessPassword), + // при неактивности в течение указанного времени (в минутах), пароль будет запрошен заново + // 0 - отключить таймаут, время доступа по паролю не ограничено + "accessTimeout": 0, + // содержимое кнопки-ссылки "(читать)", если не задано - кнопка "(читать)" не показывается // пример: "https://omnireader.ru/#/reader?url=${DOWNLOAD_LINK}" // на место ${DOWNLOAD_LINK} будет подставлена ссылка на скачивание файла книги diff --git a/client/components/Api/Api.vue b/client/components/Api/Api.vue index c2fb977..b8d8854 100644 --- a/client/components/Api/Api.vue +++ b/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); diff --git a/client/components/App.vue b/client/components/App.vue index 8e46141..c83b25b 100644 --- a/client/components/App.vue +++ b/client/components/App.vue @@ -1,10 +1,10 @@ + + + +
{ + 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); diff --git a/package-lock.json b/package-lock.json index ad7fe7f..1609bee 100644 --- a/package-lock.json +++ b/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": { diff --git a/package.json b/package.json index f89b2a4..c5015fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inpx-web", - "version": "1.3.1", + "version": "1.3.2", "author": "Book Pauk ", "license": "CC0-1.0", "repository": "bookpauk/inpx-web", diff --git a/server/config/base.js b/server/config/base.js index bd1bd53..0cc95ae 100644 --- a/server/config/base.js +++ b/server/config/base.js @@ -11,6 +11,7 @@ module.exports = { execDir, accessPassword: '', + accessTimeout: 0, bookReadLink: '', loggingEnabled: true, diff --git a/server/config/index.js b/server/config/index.js index 049bcc0..868d414 100644 --- a/server/config/index.js +++ b/server/config/index.js @@ -6,6 +6,7 @@ const branchFilename = __dirname + '/application_env'; const propsToSave = [ 'accessPassword', + 'accessTimeout', 'bookReadLink', 'loggingEnabled', 'dbCacheSize', diff --git a/server/controllers/WebSocketController.js b/server/controllers/WebSocketController.js index 566348b..eea3c01 100644 --- a/server/controllers/WebSocketController.js +++ b/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); } diff --git a/server/core/WebAccess.js b/server/core/WebAccess.js new file mode 100644 index 0000000..a272cb7 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 0bc984b..33ea96e 100644 --- a/server/index.js +++ b/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);