Merge branch 'release/1.3.2'

This commit is contained in:
Book Pauk
2022-11-27 21:05:09 +07:00
12 changed files with 258 additions and 31 deletions

View File

@@ -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} будет подставлена ссылка на скачивание файла книги

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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
server/core/WebAccess.js Normal file
View File

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

View File

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