Compare commits

...

69 Commits

Author SHA1 Message Date
Book Pauk
82f5ed4c44 Merge branch 'release/0.8.3' 2020-01-28 20:21:31 +07:00
Book Pauk
0b53ad4b4d Версия 0.8.3 2020-01-28 20:20:10 +07:00
Book Pauk
56ad41d10c Поправки текста объявления 2020-01-28 20:18:02 +07:00
Book Pauk
249a4564e0 Добавлено уведомление "Оплатим хостинг вместе" 2020-01-28 19:46:34 +07:00
Book Pauk
efb2413720 Небольшое изменение содержимого страницы 2020-01-28 19:44:52 +07:00
Book Pauk
1226acefd6 Небольшие исправления, queue теперь в одном экземпляре на класс 2020-01-28 14:51:09 +07:00
Book Pauk
76f7d7bc90 Мелкая поправка 2020-01-27 19:52:56 +07:00
Book Pauk
a5cb2641fd Мелкая поправка 2020-01-27 19:42:30 +07:00
Book Pauk
57fc64af79 Добавлен abort конвертеров при истечении времени ожидания подвижек очереди 2020-01-27 19:34:10 +07:00
Book Pauk
f8b7b8b698 Исправления LimitedQueue, исправления багов, добавлена проверка флага abort 2020-01-27 18:57:42 +07:00
Book Pauk
3da6befe10 Добавлен класс LimitedQueue для организации очередей 2020-01-26 18:38:09 +07:00
Book Pauk
a50d61c3ce Добавлена очередь скачивания и конвертирования 2020-01-26 18:37:14 +07:00
Book Pauk
b7568975e7 Добавлена обработка state = 'queue' 2020-01-26 18:31:31 +07:00
Book Pauk
4b9475310f Убрал ненужный this.taken 2020-01-26 16:23:20 +07:00
Book Pauk
639f726c83 Добавлен лимит на размер файла при распаковке 2020-01-26 15:17:45 +07:00
Book Pauk
7997c486cf Мелкий рефакторинг 2020-01-26 15:07:14 +07:00
Book Pauk
2569d00bd0 Мелкие поправки 2020-01-26 13:47:25 +07:00
Book Pauk
2cd80d8fa1 Merge tag '0.8.2-5' into develop
0.8.2-5
2020-01-23 17:12:46 +07:00
Book Pauk
eedca4db9b Merge branch 'release/0.8.2-5' 2020-01-23 17:12:37 +07:00
Book Pauk
1d352a76ce Поправка опечаток 2020-01-23 17:00:17 +07:00
Book Pauk
17670aabf9 WebSocket: добавлен метод reader-storage, поправки багов 2020-01-23 16:59:08 +07:00
Book Pauk
3456b3d90e WebSocket: добавлен метод worker-get-state-finish, небольшой рефакторинг 2020-01-23 16:25:06 +07:00
Book Pauk
f3da5a9026 Поправил комментарий 2020-01-23 15:56:26 +07:00
Book Pauk
00cc63b7cd WebSocket: добавлен метод get-config 2020-01-23 15:54:46 +07:00
Book Pauk
8df80ce738 Мелкая поправка 2020-01-23 15:16:49 +07:00
Book Pauk
12e7a783b0 Небольшие изменения блокирования кнопок панели 2020-01-22 22:06:12 +07:00
Book Pauk
be86a15351 Добавил настройку proxy_read_timeout 2020-01-22 21:37:28 +07:00
Book Pauk
2c5022e7b4 Merge tag '0.8.2-4' into develop
0.8.2-4
2020-01-22 21:17:58 +07:00
Book Pauk
f4a996fcb9 Merge branch 'release/0.8.2-4' 2020-01-22 21:17:52 +07:00
Book Pauk
fdbf508bbf Используем протокол WSS при необходимости 2020-01-22 21:17:10 +07:00
Book Pauk
500fafa5b2 Merge tag '0.8.2-3' into develop
0.8.2-3
2020-01-22 21:05:36 +07:00
Book Pauk
bfa315c68b Merge branch 'release/0.8.2-3' 2020-01-22 21:05:27 +07:00
Book Pauk
4972f085a3 Мелкая поправка 2020-01-22 20:59:52 +07:00
Book Pauk
9c13261929 Добавлена настройка для вебсокетов, добавлен конфиг nginx omnireader_http 2020-01-22 20:58:57 +07:00
Book Pauk
e36dc4a913 Небольшие поправки 2020-01-22 20:28:46 +07:00
Book Pauk
4cccb56ee3 Поправил комментарий 2020-01-22 20:15:33 +07:00
Book Pauk
3199af570d Добавлен WebSocketServer и контроллер для него 2020-01-22 20:06:51 +07:00
Book Pauk
7dad47b3c8 Добавлено использование WebSocketConnection 2020-01-22 20:02:42 +07:00
Book Pauk
fbd50bad1d Исправления багов 2020-01-22 20:02:05 +07:00
Book Pauk
10469bae7b Мелкая поправка 2020-01-22 20:01:21 +07:00
Book Pauk
b6a000a001 Добавлен пакет ws 2020-01-22 20:00:52 +07:00
Book Pauk
59539e7e90 Добавлен класс WebSocketConnection 2020-01-22 19:32:11 +07:00
Book Pauk
a2c41bc5ec Merge tag '0.8.2-2' into develop
0.8.2-2
2020-01-21 16:56:20 +07:00
Book Pauk
c4a06858fb Merge branch 'release/0.8.2-2' 2020-01-21 16:56:12 +07:00
Book Pauk
15b0f05a05 Добавил комментарий 2020-01-21 16:55:41 +07:00
Book Pauk
67feee9aa1 Поправлен баг 2020-01-21 16:53:34 +07:00
Book Pauk
185fb57b8c Удален нерабочий код 2020-01-21 16:25:30 +07:00
Book Pauk
e9039f8208 Merge tag '0.8.2-1' into develop
0.8.2-1
2020-01-21 16:14:21 +07:00
Book Pauk
440d1b3ba0 Merge branch 'release/0.8.2-1' 2020-01-21 16:14:15 +07:00
Book Pauk
9c7a6c64b0 Небольшие поправки 2020-01-21 16:13:38 +07:00
Book Pauk
7cc63fe849 Добавлена автоматическая отправка загруженной книги удаленное хранилище 2020-01-21 15:53:23 +07:00
Book Pauk
5647e8219d Мелкий рефакторинг 2020-01-21 14:58:42 +07:00
Book Pauk
81629fab7a Замена webdav-fs на webdav 2020-01-21 13:54:21 +07:00
Book Pauk
992d2033f3 Merge tag '0.8.2' into develop
0.8.2
2020-01-20 21:49:08 +07:00
Book Pauk
d52d4a1278 Merge branch 'release/0.8.2' 2020-01-20 21:49:00 +07:00
Book Pauk
57a44c5952 Версия 0.8.2 2020-01-20 21:48:31 +07:00
Book Pauk
a04161ac7c Добавил принудительную загрузку книги в обход кэша, если указан URL 2020-01-20 21:44:09 +07:00
Book Pauk
47e46f13c3 Добавлен работа с RemoteWebDavStorage, в т.ч. через api 2020-01-20 21:39:55 +07:00
Book Pauk
5535bd91c8 В конфиг добавлена опция remoteWebDavStorage 2020-01-20 21:37:31 +07:00
Book Pauk
8747a00de6 Поправлен баг 2020-01-20 21:36:44 +07:00
Book Pauk
c926b86926 Добавлен пакет webdav-fs 2020-01-20 21:22:27 +07:00
Book Pauk
010ac9aa7c Доработка api, восстановление кэшированного файла из хранилища 2020-01-20 21:21:13 +07:00
Book Pauk
4ab0c337f1 Рефакторинг 2020-01-15 16:20:46 +07:00
Book Pauk
f814c42fdd Поправлен баг в getStateFinish 2020-01-15 16:06:28 +07:00
Book Pauk
02aee3e625 Добавлена переупаковка файла книги по максимуму через 5 сек после загрузки и конвертирования 2020-01-15 15:49:45 +07:00
Book Pauk
52a32cfdd1 Добавлена обработка ошибок JSON.parse 2020-01-12 20:06:50 +07:00
Book Pauk
6faa7b2efe Уменьшение запросов get-state к api, добавлен метод get-state-finish 2020-01-12 18:51:12 +07:00
Book Pauk
f8481413c9 Мелкий рефакторинг 2020-01-12 17:03:34 +07:00
Book Pauk
0951d01383 Merge tag '0.8.1-1' into develop
0.8.1-1
2020-01-10 21:47:58 +07:00
41 changed files with 1389 additions and 136 deletions

View File

@@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import wsc from './webSocketConnection';
const api = axios.create({ const api = axios.create({
baseURL: '/api' baseURL: '/api'
@@ -6,9 +7,20 @@ const api = axios.create({
class Misc { class Misc {
async loadConfig() { async loadConfig() {
const response = await api.post('/config', {params: [
const query = {params: [
'name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'branch', 'name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'branch',
]}); ]};
try {
await wsc.open();
return await wsc.message(wsc.send(Object.assign({action: 'get-config'}, query)));
} catch (e) {
console.error(e);
}
//если с WebSocket проблема, работаем по http
const response = await api.post('/config', query);
return response.data; return response.data;
} }
} }

View File

@@ -1,6 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import * as utils from '../share/utils'; import * as utils from '../share/utils';
import wsc from './webSocketConnection';
const api = axios.create({ const api = axios.create({
baseURL: '/api/reader' baseURL: '/api/reader'
@@ -11,8 +11,67 @@ const workerApi = axios.create({
}); });
class Reader { class Reader {
constructor() {
}
async getWorkerStateFinish(workerId, callback) {
if (!callback) callback = () => {};
let response = {};
try {
await wsc.open();
const requestId = wsc.send({action: 'worker-get-state-finish', workerId});
while (1) {// eslint-disable-line no-constant-condition
response = await wsc.message(requestId);
callback(response);
if (!response.state)
throw new Error('Неверный ответ api');
if (response.state == 'finish' || response.state == 'error') {
break;
}
}
return response;
} catch (e) {
console.error(e);
}
//если с WebSocket проблема, работаем по http
const refreshPause = 500;
let i = 0;
response = {};
while (1) {// eslint-disable-line no-constant-condition
const prevProgress = response.progress || 0;
const prevState = response.state || 0;
response = await workerApi.post('/get-state', {workerId});
response = response.data;
callback(response);
if (!response.state)
throw new Error('Неверный ответ api');
if (response.state == 'finish' || response.state == 'error') {
break;
}
if (i > 0)
await utils.sleep(refreshPause);
i++;
if (i > 120*1000/refreshPause) {//2 мин ждем телодвижений воркера
throw new Error('Слишком долгое время ожидания');
}
//проверка воркера
i = (prevProgress != response.progress || prevState != response.state ? 1 : i);
}
return response;
}
async loadBook(opts, callback) { async loadBook(opts, callback) {
const refreshPause = 300;
if (!callback) callback = () => {}; if (!callback) callback = () => {};
let response = await api.post('/load-book', opts); let response = await api.post('/load-book', opts);
@@ -22,62 +81,90 @@ class Reader {
throw new Error('Неверный ответ api'); throw new Error('Неверный ответ api');
callback({totalSteps: 4}); callback({totalSteps: 4});
callback(response.data);
let i = 0; response = await this.getWorkerStateFinish(workerId, callback);
while (1) {// eslint-disable-line no-constant-condition
callback(response.data);
if (response.data.state == 'finish') {//воркер закончил работу, можно скачивать кешированный на сервере файл if (response) {
if (response.state == 'finish') {//воркер закончил работу, можно скачивать кешированный на сервере файл
callback({step: 4}); callback({step: 4});
const book = await this.loadCachedBook(response.data.path, callback); const book = await this.loadCachedBook(response.path, callback, response.size);
return Object.assign({}, response.data, {data: book.data}); return Object.assign({}, response, {data: book.data});
} }
if (response.data.state == 'error') {
let errMes = response.data.error; if (response.state == 'error') {
let errMes = response.error;
if (errMes.indexOf('getaddrinfo') >= 0 || if (errMes.indexOf('getaddrinfo') >= 0 ||
errMes.indexOf('ECONNRESET') >= 0 || errMes.indexOf('ECONNRESET') >= 0 ||
errMes.indexOf('EINVAL') >= 0 || errMes.indexOf('EINVAL') >= 0 ||
errMes.indexOf('404') >= 0) errMes.indexOf('404') >= 0)
errMes = `Ресурс не найден по адресу: ${response.data.url}`; errMes = `Ресурс не найден по адресу: ${response.url}`;
throw new Error(errMes); throw new Error(errMes);
} }
if (i > 0) } else {
await utils.sleep(refreshPause); throw new Error('Пустой ответ сервера');
}
}
i++; async checkCachedBook(url) {
if (i > 120*1000/refreshPause) {//2 мин ждем телодвижений воркера let estSize = -1;
throw new Error('Слишком долгое время ожидания'); try {
const response = await axios.head(url, {headers: {'Cache-Control': 'no-cache'}});
if (response.headers['content-length']) {
estSize = response.headers['content-length'];
}
} catch (e) {
//восстановим при необходимости файл на сервере из удаленного облака
let response = null
try {
await wsc.open();
response = await wsc.message(wsc.send({action: 'reader-restore-cached-file', path: url}));
} catch (e) {
console.error(e);
//если с WebSocket проблема, работаем по http
response = await api.post('/restore-cached-file', {path: url});
response = response.data;
}
const workerId = response.workerId;
if (!workerId)
throw new Error('Неверный ответ api');
response = await this.getWorkerStateFinish(workerId);
if (response.state == 'error') {
throw new Error(response.error);
}
if (response.size && estSize < 0) {
estSize = response.size;
} }
//проверка воркера
const prevProgress = response.data.progress;
const prevState = response.data.state;
response = await workerApi.post('/get-state', {workerId});
i = (prevProgress != response.data.progress || prevState != response.data.state ? 1 : i);
} }
return estSize;
} }
async checkUrl(url) { async loadCachedBook(url, callback, estSize = -1) {
return await axios.head(url, {headers: {'Cache-Control': 'no-cache'}}); if (!callback) callback = () => {};
}
async loadCachedBook(url, callback) {
const response = await axios.head(url);
let estSize = 1000000;
if (response.headers['content-length']) {
estSize = response.headers['content-length'];
}
callback({state: 'loading', progress: 0}); callback({state: 'loading', progress: 0});
//получение размера файла
if (estSize && estSize < 0) {
estSize = await this.checkCachedBook(url);
}
//получение файла
estSize = (estSize > 0 ? estSize : 1000000);
const options = { const options = {
onDownloadProgress: progress => { onDownloadProgress: (progress) => {
while (progress.loaded > estSize) estSize *= 1.5; while (progress.loaded > estSize) estSize *= 1.5;
if (callback) if (callback)
callback({progress: Math.round((progress.loaded*100)/estSize)}); callback({progress: Math.round((progress.loaded*100)/estSize)});
} }
} }
//загрузка
return await axios.get(url, options); return await axios.get(url, options);
} }
@@ -114,13 +201,22 @@ class Reader {
} }
async storage(request) { async storage(request) {
let response = await api.post('/storage', request); let response = null;
try {
await wsc.open();
response = await wsc.message(wsc.send({action: 'reader-storage', body: request}));
} catch (e) {
console.error(e);
//если с WebSocket проблема, работаем по http
response = await api.post('/storage', request);
response = response.data;
}
const state = response.data.state; const state = response.state;
if (!state) if (!state)
throw new Error('Неверный ответ api'); throw new Error('Неверный ответ api');
return response.data; return response;
} }
} }

View File

@@ -0,0 +1,176 @@
const cleanPeriod = 60*1000;//1 минута
class WebSocketConnection {
//messageLifeTime в минутах (cleanPeriod)
constructor(messageLifeTime = 5) {
this.ws = null;
this.timer = null;
this.listeners = [];
this.messageQueue = [];
this.messageLifeTime = messageLifeTime;
this.requestId = 0;
}
addListener(listener) {
if (this.listeners.indexOf(listener) < 0)
this.listeners.push(Object.assign({regTime: Date.now()}, listener));
}
//рассылаем сообщение и удаляем те обработчики, которые его получили
emit(mes, isError) {
const len = this.listeners.length;
if (len > 0) {
let newListeners = [];
for (const listener of this.listeners) {
let emitted = false;
if (isError) {
if (listener.onError)
listener.onError(mes);
emitted = true;
} else {
if (listener.onMessage) {
if (listener.requestId) {
if (listener.requestId === mes.requestId) {
listener.onMessage(mes);
emitted = true;
}
} else {
listener.onMessage(mes);
emitted = true;
}
} else {
emitted = true;
}
}
if (!emitted)
newListeners.push(listener);
}
this.listeners = newListeners;
}
return this.listeners.length != len;
}
open(url) {
return new Promise((resolve, reject) => {
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
resolve(this.ws);
} else {
let protocol = 'ws:';
if (window.location.protocol == 'https:') {
protocol = 'wss:'
}
url = url || `${protocol}//${window.location.host}/ws`;
this.ws = new WebSocket(url);
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => { this.periodicClean(); }, cleanPeriod);
let resolved = false;
this.ws.onopen = (e) => {
resolved = true;
resolve(e);
};
this.ws.onmessage = (e) => {
try {
const mes = JSON.parse(e.data);
this.messageQueue.push({regTime: Date.now(), mes});
let newMessageQueue = [];
for (const message of this.messageQueue) {
if (!this.emit(message.mes)) {
newMessageQueue.push(message);
}
}
this.messageQueue = newMessageQueue;
} catch (e) {
this.emit(e.message, true);
}
};
this.ws.onerror = (e) => {
this.emit(e.message, true);
if (!resolved)
reject(e);
};
}
});
}
//timeout в минутах (cleanPeriod)
message(requestId, timeout = 2) {
return new Promise((resolve, reject) => {
this.addListener({
requestId,
timeout,
onMessage: (mes) => {
if (mes.error) {
reject(mes.error);
} else {
resolve(mes);
}
},
onError: (e) => {
reject(e);
}
});
});
}
send(req) {
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
const requestId = ++this.requestId;
this.ws.send(JSON.stringify(Object.assign({requestId}, req)));
return requestId;
} else {
throw new Error('WebSocket connection is not ready');
}
}
close() {
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
this.ws.close();
}
}
periodicClean() {
try {
this.timer = null;
const now = Date.now();
//чистка listeners
let newListeners = [];
for (const listener of this.listeners) {
if (now - listener.regTime < listener.timeout*cleanPeriod - 50) {
newListeners.push(listener);
} else {
if (listener.onError)
listener.onError('Время ожидания ответа истекло');
}
}
this.listeners = newListeners;
//чистка messageQueue
let newMessageQueue = [];
for (const message of this.messageQueue) {
if (now - message.regTime < this.messageLifeTime*cleanPeriod - 50) {
newMessageQueue.push(message);
}
}
this.messageQueue = newMessageQueue;
} finally {
if (this.ws.readyState == WebSocket.OPEN) {
this.timer = setTimeout(() => { this.periodicClean(); }, cleanPeriod);
}
}
}
}
export default new WebSocketConnection();

View File

@@ -1,30 +1,54 @@
<template> <template>
<div class="page"> <div class="page">
<div class="box"> <div class="box">
<p class="p">Проект существует исключительно на личном энтузиазме.</p> <p class="p">Вы можете пожертвовать на развитие проекта любую сумму:</p>
<p class="p">Чтобы энтузиазма было побольше, вы можете пожертвовать на развитие проекта любую сумму:</p>
<div class="address"> <div class="address">
<img class="logo" src="./assets/yandex.png"> <img class="logo" src="./assets/yandex.png">
<el-button class="button" @click="donateYandexMoney">Пожертвовать</el-button><br> <el-button class="button" @click="donateYandexMoney">Пожертвовать</el-button><br>
<div class="para">{{ yandexAddress }}</div> <div class="para">{{ yandexAddress }}
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
Скопировать
</template>
<i class="el-icon-copy-document copy-icon" @click="copyAddress(yandexAddress, 'Яндекс кошелек')"></i>
</el-tooltip>
</div>
</div> </div>
<div class="address"> <div class="address">
<img class="logo" src="./assets/bitcoin.png"> <img class="logo" src="./assets/bitcoin.png">
<el-button class="button" @click="copyAddress(bitcoinAddress, 'Bitcoin')">Скопировать</el-button><br> <div class="para">{{ bitcoinAddress }}
<div class="para">{{ bitcoinAddress }}</div> <el-tooltip :open-delay="500" effect="light">
<template slot="content">
Скопировать
</template>
<i class="el-icon-copy-document copy-icon" @click="copyAddress(bitcoinAddress, 'Bitcoin-адрес')"></i>
</el-tooltip>
</div>
</div> </div>
<div class="address"> <div class="address">
<img class="logo" src="./assets/litecoin.png"> <img class="logo" src="./assets/litecoin.png">
<el-button class="button" @click="copyAddress(litecoinAddress, 'Litecoin')">Скопировать</el-button><br> <div class="para">{{ litecoinAddress }}
<div class="para">{{ litecoinAddress }}</div> <el-tooltip :open-delay="500" effect="light">
<template slot="content">
Скопировать
</template>
<i class="el-icon-copy-document copy-icon" @click="copyAddress(litecoinAddress, 'Litecoin-адрес')"></i>
</el-tooltip>
</div>
</div> </div>
<div class="address"> <div class="address">
<img class="logo" src="./assets/monero.png"> <img class="logo" src="./assets/monero.png">
<el-button class="button" @click="copyAddress(moneroAddress, 'Monero')">Скопировать</el-button><br> <div class="para">{{ moneroAddress }}
<div class="para">{{ moneroAddress }}</div> <el-tooltip :open-delay="500" effect="light">
<template slot="content">
Скопировать
</template>
<i class="el-icon-copy-document copy-icon" @click="copyAddress(moneroAddress, 'Monero-адрес')"></i>
</el-tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +78,7 @@ class DonateHelpPage extends Vue {
async copyAddress(address, prefix) { async copyAddress(address, prefix) {
const result = await copyTextToClipboard(address); const result = await copyTextToClipboard(address);
if (result) if (result)
this.$notify.success({message: `${prefix}-адрес ${address} успешно скопирован в буфер обмена`}); this.$notify.success({message: `${prefix} ${address} успешно скопирован в буфер обмена`});
else else
this.$notify.error({message: 'Копирование не удалось'}); this.$notify.error({message: 'Копирование не удалось'});
} }
@@ -106,4 +130,10 @@ h5 {
position: relative; position: relative;
top: 10px; top: 10px;
} }
.copy-icon {
margin-left: 10px;
cursor: pointer;
font-size: 120%;
}
</style> </style>

View File

@@ -112,7 +112,7 @@ class LoaderPage extends Vue {
submitUrl() { submitUrl() {
if (this.bookUrl) { if (this.bookUrl) {
this.$emit('load-book', {url: this.bookUrl}); this.$emit('load-book', {url: this.bookUrl, force: true});
this.bookUrl = ''; this.bookUrl = '';
} }
} }

View File

@@ -16,6 +16,7 @@ const ruMessage = {
'start': ' ', 'start': ' ',
'finish': ' ', 'finish': ' ',
'error': ' ', 'error': ' ',
'queue': 'очередь',
'download': 'скачивание', 'download': 'скачивание',
'decompress': 'распаковка', 'decompress': 'распаковка',
'convert': 'конвертирование', 'convert': 'конвертирование',
@@ -49,8 +50,13 @@ class ProgressPage extends Vue {
} }
setState(state) { setState(state) {
if (state.state) if (state.state) {
this.text = (ruMessage[state.state] ? ruMessage[state.state] : state.state); if (state.state == 'queue') {
this.text = 'Номер в очереди: ' + (state.place ? state.place : '');
} else {
this.text = (ruMessage[state.state] ? ruMessage[state.state] : state.state);
}
}
this.step = (state.step ? state.step : this.step); this.step = (state.step ? state.step : this.step);
this.totalSteps = (state.totalSteps > this.totalSteps ? state.totalSteps : this.totalSteps); this.totalSteps = (state.totalSteps > this.totalSteps ? state.totalSteps : this.totalSteps);
this.progress = state.progress || 0; this.progress = state.progress || 0;

View File

@@ -90,6 +90,53 @@
</span> </span>
</el-dialog> </el-dialog>
<el-dialog
title="Здравствуйте, уважаемые читатели!"
:visible.sync="donationVisible"
width="90%">
<div style="word-break: normal">
Стартовала ежегодная акция "Оплатим хостинг вместе".<br><br>
Для оплаты годового хостинга читалки, необходимо собрать около 2000 рублей.
В настоящий момент у автора эта сумма есть в наличии. Однако будет справедливо, если каждый
сможет проголосовать рублем за то, чтобы читалка так и оставалась:
<ul>
<li>непрерывно улучшаемой</li>
<li>без рекламы</li>
<li>без регистрации</li>
<li>Open Source</li>
</ul>
Автор также обращается с просьбой о помощи в распространении
<a href="https://omnireader.ru" target="_blank">ссылки</a>
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
Скопировать
</template>
<i class="el-icon-copy-document" style="cursor: pointer; font-size: 100%" @click="copyLink('https://omnireader.ru')"></i>
</el-tooltip>
на читалку через тематические форумы, соцсети, мессенджеры и пр.
Чем нас больше, тем легче оставаться на плаву и тем больше мотивации у разработчика, чтобы продолжать работать над проектом.
<br><br>
Если соберется бóльшая сумма, то разработка децентрализованной библиотеки для свободного обмена книгами будет по возможности ускорена.
<br><br>
P.S. При необходимости можно воспользоваться подходящим обменником на <a href="https://www.bestchange.ru" target="_blank">bestchange.ru</a>
<br><br>
<el-row type="flex" justify="center">
<el-button type="success" round @click="openDonate">Помочь проекту</el-button>
</el-row>
</div>
<span slot="footer" class="dialog-footer">
<span class="clickable" style="font-size: 60%; color: grey" @click="donationDialogDisable">Больше не показывать</span>
<br><br>
<el-button @click="donationDialogRemind">Напомнить позже</el-button>
</span>
</el-dialog>
</el-main> </el-main>
</el-container> </el-container>
</template> </template>
@@ -200,6 +247,7 @@ class Reader extends Vue {
whatsNewVisible = false; whatsNewVisible = false;
whatsNewContent = ''; whatsNewContent = '';
donationVisible = false;
created() { created() {
this.loading = true; this.loading = true;
@@ -258,9 +306,10 @@ class Reader extends Vue {
this.checkActivateDonateHelpPage(); this.checkActivateDonateHelpPage();
this.loading = false; this.loading = false;
await this.showWhatsNew();
this.updateRoute(); this.updateRoute();
await this.showWhatsNew();
await this.showDonation();
})(); })();
} }
@@ -272,6 +321,7 @@ class Reader extends Vue {
this.clickControl = settings.clickControl; this.clickControl = settings.clickControl;
this.blinkCachedLoad = settings.blinkCachedLoad; this.blinkCachedLoad = settings.blinkCachedLoad;
this.showWhatsNewDialog = settings.showWhatsNewDialog; this.showWhatsNewDialog = settings.showWhatsNewDialog;
this.showDonationDialog2020 = settings.showDonationDialog2020;
this.showToolButton = settings.showToolButton; this.showToolButton = settings.showToolButton;
this.enableSitesFilter = settings.enableSitesFilter; this.enableSitesFilter = settings.enableSitesFilter;
@@ -337,6 +387,41 @@ class Reader extends Vue {
} }
} }
async showDonation() {
await utils.sleep(3000);
const today = utils.formatDate(new Date(), 'coDate');
if (this.mode == 'omnireader' && today < '2020-03-01' && this.showDonationDialog2020 && this.donationRemindDate != today) {
this.donationVisible = true;
}
}
donationDialogDisable() {
this.donationVisible = false;
if (this.showDonationDialog2020) {
const newSettings = Object.assign({}, this.settings, { showDonationDialog2020: false });
this.commit('reader/setSettings', newSettings);
}
}
donationDialogRemind() {
this.donationVisible = false;
this.commit('reader/setDonationRemindDate', utils.formatDate(new Date(), 'coDate'));
}
openDonate() {
this.donationVisible = false;
this.donateToggle();
}
async copyLink(link) {
const result = await utils.copyTextToClipboard(link);
if (result)
this.$notify.success({message: `Ссылка ${link} успешно скопирована в буфер обмена`});
else
this.$notify.error({message: 'Копирование не удалось'});
}
openVersionHistory() { openVersionHistory() {
this.whatsNewVisible = false; this.whatsNewVisible = false;
this.versionHistoryToggle(); this.versionHistoryToggle();
@@ -455,6 +540,10 @@ class Reader extends Vue {
return this.$store.state.reader.whatsNewContentHash; return this.$store.state.reader.whatsNewContentHash;
} }
get donationRemindDate() {
return this.$store.state.reader.donationRemindDate;
}
addAction(pos) { addAction(pos) {
let a = this.actionList; let a = this.actionList;
if (!a.length || a[a.length - 1] != pos) { if (!a.length || a[a.length - 1] != pos) {
@@ -719,15 +808,16 @@ class Reader extends Vue {
case 'scrolling': case 'scrolling':
case 'search': case 'search':
case 'copyText': case 'copyText':
case 'recentBooks': case 'refresh':
case 'offlineMode': case 'offlineMode':
case 'recentBooks':
case 'settings': case 'settings':
if (this[`${button}Active`]) if (this.progressActive) {
classResult = classDisabled;
} else if (this[`${button}Active`]) {
classResult = classActive; classResult = classActive;
}
break; break;
}
switch (button) {
case 'undoAction': case 'undoAction':
if (this.actionCur <= 0) if (this.actionCur <= 0)
classResult = classDisabled; classResult = classDisabled;

View File

@@ -272,7 +272,7 @@ class RecentBooksPage extends Vue {
async downloadBook(fb2path) { async downloadBook(fb2path) {
try { try {
await readerApi.checkUrl(fb2path); await readerApi.checkCachedBook(fb2path);
const d = this.$refs.download; const d = this.$refs.download;
d.href = fb2path; d.href = fb2path;

View File

@@ -471,6 +471,14 @@
<el-checkbox v-model="showWhatsNewDialog">Показывать уведомление "Что нового"</el-checkbox> <el-checkbox v-model="showWhatsNewDialog">Показывать уведомление "Что нового"</el-checkbox>
</el-tooltip> </el-tooltip>
</el-form-item> </el-form-item>
<el-form-item label="Уведомление">
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
Показывать уведомление "Оплатим хостинг вместе"
</template>
<el-checkbox v-model="showDonationDialog2020">Показывать "Оплатим хостинг вместе"</el-checkbox>
</el-tooltip>
</el-form-item>
</el-form> </el-form>
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent> <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>

View File

@@ -464,7 +464,7 @@ class BookManager {
addEventListener(listener) { addEventListener(listener) {
if (this.eventListeners.indexOf(listener) < 0) if (this.eventListeners.indexOf(listener) < 0)
this.eventListeners.push(listener); this.eventListeners.push(listener);
} }
removeEventListener(listener) { removeEventListener(listener) {

View File

@@ -1,4 +1,27 @@
export const versionHistory = [ export const versionHistory = [
{
showUntil: '2020-01-27',
header: '0.8.3 (2020-01-28)',
content:
`
<ul>
<li>добавлено всплывающее окно с акцией "Оплатим хостинг вместе"</li>
<li>внутренние оптимизации</li>
</ul>
`
},
{
showUntil: '2020-01-19',
header: '0.8.2 (2020-01-20)',
content:
`
<ul>
<li>внутренние оптимизации</li>
</ul>
`
},
{ {
showUntil: '2020-01-06', showUntil: '2020-01-06',
header: '0.8.1 (2020-01-07)', header: '0.8.1 (2020-01-07)',

View File

@@ -19,6 +19,7 @@ import ElCheckbox from 'element-ui/lib/checkbox';
import ElTabs from 'element-ui/lib/tabs'; import ElTabs from 'element-ui/lib/tabs';
import ElTabPane from 'element-ui/lib/tab-pane'; import ElTabPane from 'element-ui/lib/tab-pane';
import ElTooltip from 'element-ui/lib/tooltip'; import ElTooltip from 'element-ui/lib/tooltip';
import ElRow from 'element-ui/lib/row';
import ElCol from 'element-ui/lib/col'; import ElCol from 'element-ui/lib/col';
import ElContainer from 'element-ui/lib/container'; import ElContainer from 'element-ui/lib/container';
import ElAside from 'element-ui/lib/aside'; import ElAside from 'element-ui/lib/aside';
@@ -43,7 +44,7 @@ import MessageBox from 'element-ui/lib/message-box';
const components = { const components = {
ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip, ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip,
ElCol, ElContainer, ElAside, ElMain, ElHeader, ElRow, ElCol, ElContainer, ElAside, ElMain, ElHeader,
ElInput, ElInputNumber, ElSelect, ElOption, ElTable, ElTableColumn, ElInput, ElInputNumber, ElSelect, ElOption, ElTable, ElTableColumn,
ElProgress, ElSlider, ElForm, ElFormItem, ElProgress, ElSlider, ElForm, ElFormItem,
ElColorPicker, ElDialog, ElColorPicker, ElDialog,

View File

@@ -182,6 +182,7 @@ const settingDefaults = {
imageFitWidth: true, imageFitWidth: true,
showServerStorageMessages: true, showServerStorageMessages: true,
showWhatsNewDialog: true, showWhatsNewDialog: true,
showDonationDialog2020: true,
enableSitesFilter: true, enableSitesFilter: true,
fontShifts: {}, fontShifts: {},
@@ -204,6 +205,7 @@ const state = {
profilesRev: 0, profilesRev: 0,
allowProfilesSave: false,//подстраховка для разработки allowProfilesSave: false,//подстраховка для разработки
whatsNewContentHash: '', whatsNewContentHash: '',
donationRemindDate: '',
currentProfile: '', currentProfile: '',
settings: Object.assign({}, settingDefaults), settings: Object.assign({}, settingDefaults),
settingsRev: {}, settingsRev: {},
@@ -238,6 +240,9 @@ const mutations = {
setWhatsNewContentHash(state, value) { setWhatsNewContentHash(state, value) {
state.whatsNewContentHash = value; state.whatsNewContentHash = value;
}, },
setDonationRemindDate(state, value) {
state.donationRemindDate = value;
},
setCurrentProfile(state, value) { setCurrentProfile(state, value) {
state.currentProfile = value; state.currentProfile = value;
}, },

View File

@@ -8,6 +8,7 @@ server {
server_name omnireader.ru; server_name omnireader.ru;
client_max_body_size 50m; client_max_body_size 50m;
proxy_read_timeout 1h;
gzip on; gzip on;
gzip_min_length 1024; gzip_min_length 1024;
@@ -18,6 +19,13 @@ server {
proxy_pass http://127.0.0.1:44081; proxy_pass http://127.0.0.1:44081;
} }
location /ws {
proxy_pass http://127.0.0.1:44081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / { location / {
root /home/liberama/public; root /home/liberama/public;

View File

@@ -0,0 +1,59 @@
server {
listen 80;
server_name omnireader.ru;
client_max_body_size 50m;
proxy_read_timeout 1h;
gzip on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location /api {
proxy_pass http://127.0.0.1:44081;
}
location /ws {
proxy_pass http://127.0.0.1:44081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
root /home/liberama/public;
location /tmp {
add_header Content-Type text/xml;
add_header Content-Encoding gzip;
}
location ~* \.(?:manifest|appcache|html)$ {
expires -1;
}
}
}
server {
listen 80;
server_name old.omnireader.ru;
client_max_body_size 50m;
gzip on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
root /home/oldreader;
index index.html;
# Обработка php файлов с помощью fpm
location ~ \.php$ {
try_files $uri =404;
include /etc/nginx/fastcgi.conf;
fastcgi_pass unix:/run/php/php7.2-fpm.sock;
}
}

91
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "Liberama", "name": "Liberama",
"version": "0.8.1", "version": "0.8.2",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -1725,6 +1725,11 @@
} }
} }
}, },
"base-64": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
"integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs="
},
"base-x": { "base-x": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.7.tgz", "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.7.tgz",
@@ -5719,6 +5724,11 @@
"parse-passwd": "^1.0.0" "parse-passwd": "^1.0.0"
} }
}, },
"hot-patcher": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-0.5.0.tgz",
"integrity": "sha512-2Uu2W0s8+dnqXzdlg0MRsRzPoDCs1wVjOGSyMRRaMzLDX4bgHw6xDYKccsWafXPPxQpkQfEjgW6+17pwcg60bw=="
},
"hsl-regex": { "hsl-regex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz",
@@ -6902,6 +6912,11 @@
} }
} }
}, },
"merge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz",
"integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ=="
},
"merge-descriptors": { "merge-descriptors": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
@@ -7882,6 +7897,11 @@
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"dev": true "dev": true
}, },
"path-posix": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz",
"integrity": "sha1-BrJhE/Vr6rBCVFojv6iAA8ysJg8="
},
"path-to-regexp": { "path-to-regexp": {
"version": "0.1.7", "version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@@ -10423,6 +10443,11 @@
"integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
"dev": true "dev": true
}, },
"querystringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz",
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
},
"randombytes": { "randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -10709,6 +10734,11 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true "dev": true
}, },
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"resize-observer-polyfill": { "resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -12489,6 +12519,11 @@
} }
} }
}, },
"url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="
},
"url-loader": { "url-loader": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-1.1.2.tgz", "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-1.1.2.tgz",
@@ -12508,6 +12543,15 @@
} }
} }
}, },
"url-parse": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz",
"integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"url-parse-lax": { "url-parse-lax": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
@@ -12737,6 +12781,32 @@
"neo-async": "^2.5.0" "neo-async": "^2.5.0"
} }
}, },
"webdav": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-2.10.1.tgz",
"integrity": "sha512-3UfnjGTAqSM9MW3Rpt1KrY1KneYK0wPCFryHTncqw1OP1pyiniT3uYhVpgmH6za/TkWOfnTnKCDKhwrLJFdzow==",
"requires": {
"axios": "^0.19.0",
"base-64": "^0.1.0",
"hot-patcher": "^0.5.0",
"merge": "^1.2.1",
"minimatch": "^3.0.4",
"path-posix": "^1.0.0",
"url-join": "^4.0.1",
"url-parse": "^1.4.7",
"xml2js": "^0.4.19"
},
"dependencies": {
"axios": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.1.tgz",
"integrity": "sha512-Yl+7nfreYKaLRvAvjNPkvfjnQHJM1yLBY3zhqAwcJSwR/6ETkanUgylgtIvkvz0xJ+p/vZuNw8X7Hnb7Whsbpw==",
"requires": {
"follow-redirects": "1.5.10"
}
}
}
},
"webpack": { "webpack": {
"version": "4.40.2", "version": "4.40.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-4.40.2.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.40.2.tgz",
@@ -13005,6 +13075,25 @@
"mkdirp": "^0.5.1" "mkdirp": "^0.5.1"
} }
}, },
"ws": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz",
"integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A=="
},
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
},
"xtend": { "xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "Liberama", "name": "Liberama",
"version": "0.8.1", "version": "0.8.3",
"author": "Book Pauk <bookpauk@gmail.com>", "author": "Book Pauk <bookpauk@gmail.com>",
"license": "CC0-1.0", "license": "CC0-1.0",
"repository": "bookpauk/liberama", "repository": "bookpauk/liberama",
@@ -84,6 +84,8 @@
"vue-router": "^3.1.3", "vue-router": "^3.1.3",
"vuex": "^3.1.1", "vuex": "^3.1.1",
"vuex-persistedstate": "^2.5.4", "vuex-persistedstate": "^2.5.4",
"webdav": "^2.10.1",
"ws": "^7.2.1",
"zip-stream": "^2.1.2" "zip-stream": "^2.1.2"
} }
} }

View File

@@ -21,7 +21,7 @@ module.exports = {
maxTempPublicDirSize: 512*1024*1024,//512Мб maxTempPublicDirSize: 512*1024*1024,//512Мб
maxUploadPublicDirSize: 200*1024*1024,//100Мб maxUploadPublicDirSize: 200*1024*1024,//100Мб
useExternalBookConverter: false, useExternalBookConverter: false,
db: [ db: [
{ {
@@ -45,5 +45,14 @@ module.exports = {
}, },
], ],
remoteWebDavStorage: false,
/*
remoteWebDavStorage: {
url: '127.0.0.1:1900',
username: '',
password: '',
},
*/
}; };

View File

@@ -10,6 +10,7 @@ const propsToSave = [
'useExternalBookConverter', 'useExternalBookConverter',
'servers', 'servers',
'remoteWebDavStorage',
]; ];
let instance = null; let instance = null;

View File

@@ -35,9 +35,9 @@ class ReaderController extends BaseController {
const request = req.body; const request = req.body;
let error = ''; let error = '';
try { try {
if (!request.action) if (!request.action)
throw new Error(`key 'action' is empty`); throw new Error(`key 'action' is empty`);
if (!request.items || Array.isArray(request.data)) if (!request.items || Array.isArray(request.data))
throw new Error(`key 'items' is empty`); throw new Error(`key 'items' is empty`);
return await this.readerStorage.doAction(request); return await this.readerStorage.doAction(request);
@@ -62,6 +62,24 @@ class ReaderController extends BaseController {
res.status(400).send({error}); res.status(400).send({error});
return false; return false;
} }
async restoreCachedFile(req, res) {
const request = req.body;
let error = '';
try {
if (!request.path)
throw new Error(`key 'path' is empty`);
const workerId = this.readerWorker.restoreCachedFile(request.path);
const state = this.workerState.getState(workerId);
return (state ? state : {});
} catch (e) {
error = e.message;
}
//bad request
res.status(400).send({error});
return false;
}
} }
module.exports = ReaderController; module.exports = ReaderController;

View File

@@ -0,0 +1,164 @@
const WebSocket = require ('ws');
const _ = require('lodash');
const ReaderWorker = require('../core/Reader/ReaderWorker');//singleton
const ReaderStorage = require('../core/Reader/ReaderStorage');//singleton
const WorkerState = require('../core/WorkerState');//singleton
const log = new (require('../core/AppLogger'))().log;//singleton
const utils = require('../core/utils');
const cleanPeriod = 1*60*1000;//1 минута
const closeSocketOnIdle = 5*60*1000;//5 минут
class WebSocketController {
constructor(wss, config) {
this.config = config;
this.isDevelopment = (config.branch == 'development');
this.readerStorage = new ReaderStorage();
this.readerWorker = new ReaderWorker(config);
this.workerState = new WorkerState();
this.wss = wss;
wss.on('connection', (ws) => {
ws.on('message', (message) => {
this.onMessage(ws, message);
});
});
setTimeout(() => { this.periodicClean(); }, cleanPeriod);
}
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 onMessage(ws, message) {
let req = {};
try {
if (this.isDevelopment) {
log(`WebSocket-IN: ${message.substr(0, 4000)}`);
}
ws.lastActivity = Date.now();
req = JSON.parse(message);
switch (req.action) {
case 'test':
await this.test(req, ws); break;
case 'get-config':
await this.getConfig(req, ws); break;
case 'worker-get-state':
await this.workerGetState(req, ws); break;
case 'worker-get-state-finish':
await this.workerGetStateFinish(req, ws); break;
case 'reader-restore-cached-file':
await this.readerRestoreCachedFile(req, ws); break;
case 'reader-storage':
await this.readerStorageDo(req, ws); break;
default:
throw new Error(`Action not found: ${req.action}`);
}
} catch (e) {
this.send({error: e.message}, req, ws);
}
}
send(res, req, ws) {
if (ws.readyState == WebSocket.OPEN) {
ws.lastActivity = Date.now();
let r = res;
if (req.requestId)
r = Object.assign({requestId: req.requestId}, r);
const message = JSON.stringify(r);
ws.send(message);
if (this.isDevelopment) {
log(`WebSocket-OUT: ${message.substr(0, 4000)}`);
}
}
}
//Actions ------------------------------------------------------------------
async test(req, ws) {
this.send({message: 'Liberama project is awesome'}, req, ws);
}
async getConfig(req, ws) {
if (Array.isArray(req.params)) {
this.send(_.pick(this.config, req.params), req, ws);
} else {
throw new Error('params is not an array');
}
}
async workerGetState(req, ws) {
if (!req.workerId)
throw new Error(`key 'workerId' is wrong`);
const state = this.workerState.getState(req.workerId);
this.send((state ? state : {}), req, ws);
}
async workerGetStateFinish(req, ws) {
if (!req.workerId)
throw new Error(`key 'workerId' is wrong`);
const refreshPause = 200;
let i = 0;
let state = {};
while (1) {// eslint-disable-line no-constant-condition
const prevProgress = state.progress || -1;
const prevState = state.state || '';
state = this.workerState.getState(req.workerId);
this.send((state ? state : {}), req, ws);
if (!state) break;
if (state.state != 'finish' && state.state != 'error')
await utils.sleep(refreshPause);
else
break;
i++;
if (i > 2*60*1000/refreshPause) {//2 мин ждем телодвижений воркера
this.send({state: 'error', error: 'Время ожидания процесса истекло'}, req, ws);
}
i = (prevProgress != state.progress || prevState != state.state ? 1 : i);
}
}
async readerRestoreCachedFile(req, ws) {
if (!req.path)
throw new Error(`key 'path' is empty`);
const workerId = this.readerWorker.restoreCachedFile(req.path);
const state = this.workerState.getState(workerId);
this.send((state ? state : {}), req, ws);
}
async readerStorageDo(req, ws) {
if (!req.body)
throw new Error(`key 'body' is empty`);
if (!req.body.action)
throw new Error(`key 'action' is empty`);
if (!req.body.items || Array.isArray(req.body.data))
throw new Error(`key 'items' is empty`);
this.send(await this.readerStorage.doAction(req.body), req, ws);
}
}
module.exports = WebSocketController;

View File

@@ -1,5 +1,6 @@
const BaseController = require('./BaseController'); const BaseController = require('./BaseController');
const WorkerState = require('../core/WorkerState');//singleton const WorkerState = require('../core/WorkerState');//singleton
const utils = require('../core/utils');
class WorkerController extends BaseController { class WorkerController extends BaseController {
constructor(config) { constructor(config) {
@@ -15,6 +16,7 @@ class WorkerController extends BaseController {
throw new Error(`key 'workerId' is wrong`); throw new Error(`key 'workerId' is wrong`);
const state = this.workerState.getState(request.workerId); const state = this.workerState.getState(request.workerId);
return (state ? state : {}); return (state ? state : {});
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -23,6 +25,60 @@ class WorkerController extends BaseController {
res.status(400).send({error}); res.status(400).send({error});
return false; return false;
} }
//TODO: удалить бесполезную getStateFinish
async getStateFinish(req, res) {
const request = req.body;
let error = '';
try {
if (!request.workerId)
throw new Error(`key 'workerId' is wrong`);
res.writeHead(200, {
'Content-Type': 'text/json; charset=utf-8',
});
const splitter = '-- aod2t5hDXU32bUFyqlFE next status --';
const refreshPause = 200;
let i = 0;
let prevProgress = -1;
let prevState = '';
let state;
while (1) {// eslint-disable-line no-constant-condition
state = this.workerState.getState(request.workerId);
if (!state) break;
res.write(splitter + JSON.stringify(state));
res.flush();
if (state.state != 'finish' && state.state != 'error')
await utils.sleep(refreshPause);
else
break;
i++;
if (i > 2*60*1000/refreshPause) {//2 мин ждем телодвижений воркера
res.write(splitter + JSON.stringify({state: 'error', error: 'Слишком долгое время ожидания'}));
break;
}
i = (prevProgress != state.progress || prevState != state.state ? 1 : i);
prevProgress = state.progress;
prevState = state.state;
}
if (!state) {
res.write(splitter + JSON.stringify({}));
}
res.end();
return false;
} catch (e) {
error = e.message;
}
//bad request
res.status(400).send({error});
return false;
}
} }
module.exports = WorkerController; module.exports = WorkerController;

View File

@@ -2,4 +2,5 @@ module.exports = {
MiscController: require('./MiscController'), MiscController: require('./MiscController'),
ReaderController: require('./ReaderController'), ReaderController: require('./ReaderController'),
WorkerController: require('./WorkerController'), WorkerController: require('./WorkerController'),
WebSocketController: require('./WebSocketController'),
} }

View File

@@ -5,12 +5,14 @@ const unbzip2Stream = require('unbzip2-stream');
const tar = require('tar-fs'); const tar = require('tar-fs');
const ZipStreamer = require('./ZipStreamer'); const ZipStreamer = require('./ZipStreamer');
const appLogger = new (require('./AppLogger'))();//singleton
const utils = require('./utils'); const utils = require('./utils');
const FileDetector = require('./FileDetector'); const FileDetector = require('./FileDetector');
class FileDecompressor { class FileDecompressor {
constructor() { constructor(limitFileSize = 0) {
this.detector = new FileDetector(); this.detector = new FileDetector();
this.limitFileSize = limitFileSize;
} }
async decompressNested(filename, outputDir) { async decompressNested(filename, outputDir) {
@@ -112,7 +114,7 @@ class FileDecompressor {
async unZip(filename, outputDir) { async unZip(filename, outputDir) {
const zip = new ZipStreamer(); const zip = new ZipStreamer();
return await zip.unpack(filename, outputDir); return await zip.unpack(filename, outputDir, null, this.limitFileSize);
} }
unBz2(filename, outputDir) { unBz2(filename, outputDir) {
@@ -124,9 +126,16 @@ class FileDecompressor {
} }
unTar(filename, outputDir) { unTar(filename, outputDir) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => { (async() => {
const files = []; const files = [];
if (this.limitFileSize) {
if ((await fs.stat(filename)).size > this.limitFileSize) {
reject('Файл слишком большой');
return;
}
}
const tarExtract = tar.extract(outputDir, { const tarExtract = tar.extract(outputDir, {
map: (header) => { map: (header) => {
files.push({path: header.name, size: header.size}); files.push({path: header.name, size: header.size});
@@ -148,7 +157,7 @@ class FileDecompressor {
}); });
inputStream.pipe(tarExtract); inputStream.pipe(tarExtract);
}); })().catch(reject); });
} }
decompressByStream(stream, filename, outputDir) { decompressByStream(stream, filename, outputDir) {
@@ -173,6 +182,16 @@ class FileDecompressor {
}); });
stream.on('error', reject); stream.on('error', reject);
if (this.limitFileSize) {
let readSize = 0;
stream.on('data', (buffer) => {
readSize += buffer.length;
if (readSize > this.limitFileSize)
stream.destroy(new Error('Файл слишком большой'));
});
}
inputStream.on('error', reject); inputStream.on('error', reject);
outputStream.on('error', reject); outputStream.on('error', reject);
@@ -189,9 +208,9 @@ class FileDecompressor {
}); });
} }
async gzipFile(inputFile, outputFile) { async gzipFile(inputFile, outputFile, level = 1) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const gzip = zlib.createGzip({level: 1}); const gzip = zlib.createGzip({level});
const input = fs.createReadStream(inputFile); const input = fs.createReadStream(inputFile);
const output = fs.createWriteStream(outputFile); const output = fs.createWriteStream(outputFile);
@@ -208,7 +227,21 @@ class FileDecompressor {
const outFilename = `${outDir}/${hash}`; const outFilename = `${outDir}/${hash}`;
if (!await fs.pathExists(outFilename)) { if (!await fs.pathExists(outFilename)) {
await this.gzipFile(filename, outFilename); await this.gzipFile(filename, outFilename, 1);
// переупакуем через некоторое время на максималках
const filenameCopy = `${filename}.copy`;
await fs.copy(filename, filenameCopy);
(async() => {
await utils.sleep(5000);
const filenameGZ = `${filename}.gz`;
await this.gzipFile(filenameCopy, filenameGZ, 9);
await fs.move(filenameGZ, outFilename, {overwrite: true});
await fs.remove(filenameCopy);
})().catch((e) => { if (appLogger.inited) appLogger.log(LM_ERR, `FileDecompressor.gzipFileIfNotExists: ${e.message}`) });
} else { } else {
await utils.touchFile(outFilename); await utils.touchFile(outFilename);
} }

View File

@@ -1,12 +1,11 @@
const got = require('got'); const got = require('got');
const maxDownloadSize = 50*1024*1024;
class FileDownloader { class FileDownloader {
constructor() { constructor(limitDownloadSize = 0) {
this.limitDownloadSize = limitDownloadSize;
} }
async load(url, callback) { async load(url, callback, abort) {
let errMes = ''; let errMes = '';
const options = { const options = {
encoding: null, encoding: null,
@@ -23,10 +22,14 @@ class FileDownloader {
} }
let prevProg = 0; let prevProg = 0;
const request = got(url, options).on('downloadProgress', progress => { const request = got(url, options);
if (progress.transferred > maxDownloadSize) {
errMes = 'file too big'; request.on('downloadProgress', progress => {
request.cancel(); if (this.limitDownloadSize) {
if (progress.transferred > this.limitDownloadSize) {
errMes = 'Файл слишком большой';
request.cancel();
}
} }
let prog = 0; let prog = 0;
@@ -38,8 +41,12 @@ class FileDownloader {
if (prog != prevProg && callback) if (prog != prevProg && callback)
callback(prog); callback(prog);
prevProg = prog; prevProg = prog;
});
if (abort && abort()) {
errMes = 'abort';
request.cancel();
}
});
try { try {
return (await request).body; return (await request).body;

119
server/core/LimitedQueue.js Normal file
View File

@@ -0,0 +1,119 @@
class LimitedQueue {
constructor(enqueueAfter = 10, size = 100, timeout = 60*60*1000) {//timeout в ms
this.size = size;
this.timeout = timeout;
this.abortCount = 0;
this.enqueueAfter = enqueueAfter;
this.freed = enqueueAfter;
this.listeners = [];
}
_addListener(listener) {
this.listeners.push(listener);
}
//отсылаем сообщение первому ожидающему и удаляем его из списка
_emitFree() {
if (this.listeners.length > 0) {
let listener = this.listeners.shift();
listener.onFree();
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onPlaceChange(i + 1);
}
}
}
get(onPlaceChange) {
return new Promise((resolve, reject) => {
if (this.destroyed)
reject('destroyed');
const take = () => {
if (this.freed <= 0)
throw new Error('Ошибка получения ресурсов в очереди ожидания');
this.freed--;
this.resetTimeout();
let aCount = this.abortCount;
return {
ret: () => {
if (aCount == this.abortCount) {
this.freed++;
this._emitFree();
aCount = -1;
this.resetTimeout();
}
},
abort: () => {
return (aCount != this.abortCount);
},
resetTimeout: this.resetTimeout.bind(this)
};
};
if (this.freed > 0) {
resolve(take());
} else {
if (this.listeners.length < this.size) {
this._addListener({
onFree: () => {
resolve(take());
},
onError: (err) => {
reject(err);
},
onPlaceChange: (i) => {
if (onPlaceChange)
onPlaceChange(i);
}
});
if (onPlaceChange)
onPlaceChange(this.listeners.length);
} else {
reject('Превышен размер очереди ожидания');
}
}
});
}
resetTimeout() {
if (this.timer)
clearTimeout(this.timer);
this.timer = setTimeout(() => { this.clean(); }, this.timeout);
}
clean() {
this.timer = null;
if (this.freed < this.enqueueAfter) {
this.abortCount++;
//чистка listeners
for (const listener of this.listeners) {
listener.onError('Время ожидания в очереди истекло');
}
this.listeners = [];
this.freed = this.enqueueAfter;
}
}
destroy() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
for (const listener of this.listeners) {
listener.onError('destroy');
}
this.listeners = [];
this.abortCount++;
this.destroyed = true;
}
}
module.exports = LimitedQueue;

View File

@@ -3,10 +3,11 @@ const iconv = require('iconv-lite');
const chardet = require('chardet'); const chardet = require('chardet');
const he = require('he'); const he = require('he');
const LimitedQueue = require('../../LimitedQueue');
const textUtils = require('./textUtils'); const textUtils = require('./textUtils');
const utils = require('../../utils'); const utils = require('../../utils');
let execConverterCounter = 0; const queue = new LimitedQueue(2, 20, 3*60*1000);//3 минуты ожидание подвижек
class ConvertBase { class ConvertBase {
constructor(config) { constructor(config) {
@@ -32,13 +33,16 @@ class ConvertBase {
throw new Error('Внешний конвертер pdftohtml не найден'); throw new Error('Внешний конвертер pdftohtml не найден');
} }
async execConverter(path, args, onData) { async execConverter(path, args, onData, abort) {
execConverterCounter++; let q = null;
try { try {
if (execConverterCounter > 10) q = await queue.get(() => {onData();});
throw new Error('Слишком большая очередь конвертирования. Пожалуйста, попробуйте позже.'); } catch (e) {
throw new Error('Слишком большая очередь конвертирования. Пожалуйста, попробуйте позже.');
}
const result = await utils.spawnProcess(path, {args, onData}); try {
const result = await utils.spawnProcess(path, {args, onData, abort});
if (result.code != 0) { if (result.code != 0) {
let error = result.code; let error = result.code;
if (this.config.branch == 'development') if (this.config.branch == 'development')
@@ -48,13 +52,15 @@ class ConvertBase {
} catch(e) { } catch(e) {
if (e.status == 'killed') { if (e.status == 'killed') {
throw new Error('Слишком долгое ожидание конвертера'); throw new Error('Слишком долгое ожидание конвертера');
} else if (e.status == 'abort') {
throw new Error('abort');
} else if (e.status == 'error') { } else if (e.status == 'error') {
throw new Error(e.error); throw new Error(e.error);
} else { } else {
throw new Error(e); throw new Error(e);
} }
} finally { } finally {
execConverterCounter--; q.ret();
} }
} }

View File

@@ -16,7 +16,7 @@ class ConvertDoc extends ConvertDocX {
return false; return false;
await this.checkExternalConverterPresent(); await this.checkExternalConverterPresent();
const {inputFiles, callback} = opts; const {inputFiles, callback, abort} = opts;
const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`; const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`;
const docFile = `${outFile}.doc`; const docFile = `${outFile}.doc`;
@@ -24,9 +24,9 @@ class ConvertDoc extends ConvertDocX {
const fb2File = `${outFile}.fb2`; const fb2File = `${outFile}.fb2`;
await fs.copy(inputFiles.sourceFile, docFile); await fs.copy(inputFiles.sourceFile, docFile);
await this.execConverter(this.sofficePath, ['--headless', '--convert-to', 'docx', '--outdir', inputFiles.filesDir, docFile]); await this.execConverter(this.sofficePath, ['--headless', '--convert-to', 'docx', '--outdir', inputFiles.filesDir, docFile], null, abort);
return await super.convert(docxFile, fb2File, callback); return await super.convert(docxFile, fb2File, callback, abort);
} }
} }

View File

@@ -20,12 +20,12 @@ class ConvertDocX extends ConvertBase {
return false; return false;
} }
async convert(docxFile, fb2File, callback) { async convert(docxFile, fb2File, callback, abort) {
let perc = 0; let perc = 0;
await this.execConverter(this.calibrePath, [docxFile, fb2File], () => { await this.execConverter(this.calibrePath, [docxFile, fb2File], () => {
perc = (perc < 100 ? perc + 5 : 50); perc = (perc < 100 ? perc + 5 : 50);
callback(perc); callback(perc);
}); }, abort);
return await fs.readFile(fb2File); return await fs.readFile(fb2File);
} }
@@ -35,7 +35,7 @@ class ConvertDocX extends ConvertBase {
return false; return false;
await this.checkExternalConverterPresent(); await this.checkExternalConverterPresent();
const {inputFiles, callback} = opts; const {inputFiles, callback, abort} = opts;
const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`; const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`;
const docxFile = `${outFile}.docx`; const docxFile = `${outFile}.docx`;
@@ -43,7 +43,7 @@ class ConvertDocX extends ConvertBase {
await fs.copy(inputFiles.sourceFile, docxFile); await fs.copy(inputFiles.sourceFile, docxFile);
return await this.convert(docxFile, fb2File, callback); return await this.convert(docxFile, fb2File, callback, abort);
} }
} }

View File

@@ -28,7 +28,7 @@ class ConvertEpub extends ConvertBase {
return false; return false;
await this.checkExternalConverterPresent(); await this.checkExternalConverterPresent();
const {inputFiles, callback} = opts; const {inputFiles, callback, abort} = opts;
const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`; const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`;
const epubFile = `${outFile}.epub`; const epubFile = `${outFile}.epub`;
@@ -40,7 +40,7 @@ class ConvertEpub extends ConvertBase {
await this.execConverter(this.calibrePath, [epubFile, fb2File], () => { await this.execConverter(this.calibrePath, [epubFile, fb2File], () => {
perc = (perc < 100 ? perc + 5 : 50); perc = (perc < 100 ? perc + 5 : 50);
callback(perc); callback(perc);
}); }, abort);
return await fs.readFile(fb2File); return await fs.readFile(fb2File);
} }

View File

@@ -16,7 +16,7 @@ class ConvertMobi extends ConvertBase {
return false; return false;
await this.checkExternalConverterPresent(); await this.checkExternalConverterPresent();
const {inputFiles, callback} = opts; const {inputFiles, callback, abort} = opts;
const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`; const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`;
const mobiFile = `${outFile}.mobi`; const mobiFile = `${outFile}.mobi`;
@@ -28,7 +28,7 @@ class ConvertMobi extends ConvertBase {
await this.execConverter(this.calibrePath, [mobiFile, fb2File], () => { await this.execConverter(this.calibrePath, [mobiFile, fb2File], () => {
perc = (perc < 100 ? perc + 5 : 50); perc = (perc < 100 ? perc + 5 : 50);
callback(perc); callback(perc);
}); }, abort);
return await fs.readFile(fb2File); return await fs.readFile(fb2File);
} }

View File

@@ -18,7 +18,7 @@ class ConvertPdf extends ConvertHtml {
return false; return false;
await this.checkExternalConverterPresent(); await this.checkExternalConverterPresent();
const {inputFiles, callback} = opts; const {inputFiles, callback, abort} = opts;
const outFile = `${inputFiles.filesDir}/${utils.randomHexString(10)}.xml`; const outFile = `${inputFiles.filesDir}/${utils.randomHexString(10)}.xml`;
@@ -27,7 +27,7 @@ class ConvertPdf extends ConvertHtml {
await this.execConverter(this.pdfToHtmlPath, ['-c', '-s', '-xml', inputFiles.sourceFile, outFile], () => { await this.execConverter(this.pdfToHtmlPath, ['-c', '-s', '-xml', inputFiles.sourceFile, outFile], () => {
perc = (perc < 80 ? perc + 10 : 40); perc = (perc < 80 ? perc + 10 : 40);
callback(perc); callback(perc);
}); }, abort);
callback(80); callback(80);
const data = await fs.readFile(outFile); const data = await fs.readFile(outFile);

View File

@@ -16,7 +16,7 @@ class ConvertRtf extends ConvertDocX {
return false; return false;
await this.checkExternalConverterPresent(); await this.checkExternalConverterPresent();
const {inputFiles, callback} = opts; const {inputFiles, callback, abort} = opts;
const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`; const outFile = `${inputFiles.filesDir}/${path.basename(inputFiles.sourceFile)}`;
const rtfFile = `${outFile}.rtf`; const rtfFile = `${outFile}.rtf`;
@@ -24,9 +24,9 @@ class ConvertRtf extends ConvertDocX {
const fb2File = `${outFile}.fb2`; const fb2File = `${outFile}.fb2`;
await fs.copy(inputFiles.sourceFile, rtfFile); await fs.copy(inputFiles.sourceFile, rtfFile);
await this.execConverter(this.sofficePath, ['--headless', '--convert-to', 'docx', '--outdir', inputFiles.filesDir, rtfFile]); await this.execConverter(this.sofficePath, ['--headless', '--convert-to', 'docx', '--outdir', inputFiles.filesDir, rtfFile], null, abort);
return await super.convert(docxFile, fb2File, callback); return await super.convert(docxFile, fb2File, callback, abort);
} }
} }

View File

@@ -26,11 +26,14 @@ class BookConverter {
} }
} }
async convertToFb2(inputFiles, outputFile, opts, callback) { async convertToFb2(inputFiles, outputFile, opts, callback, abort) {
if (abort && abort())
throw new Error('abort');
const selectedFileType = await this.detector.detectFile(inputFiles.selectedFile); const selectedFileType = await this.detector.detectFile(inputFiles.selectedFile);
const data = await fs.readFile(inputFiles.selectedFile); const data = await fs.readFile(inputFiles.selectedFile);
const convertOpts = Object.assign({}, opts, {inputFiles, callback, dataType: selectedFileType}); const convertOpts = Object.assign({}, opts, {inputFiles, callback, abort, dataType: selectedFileType});
let result = false; let result = false;
for (const convert of this.convertFactory) { for (const convert of this.convertFactory) {
result = await convert.run(data, convertOpts); result = await convert.run(data, convertOpts);
@@ -41,7 +44,7 @@ class BookConverter {
} }
if (!result && inputFiles.nesting) { if (!result && inputFiles.nesting) {
result = await this.convertToFb2(inputFiles.nesting, outputFile, opts, callback); result = await this.convertToFb2(inputFiles.nesting, outputFile, opts, callback, abort);
} }
if (!result) { if (!result) {

View File

@@ -1,14 +1,19 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const LimitedQueue = require('../LimitedQueue');
const WorkerState = require('../WorkerState');//singleton const WorkerState = require('../WorkerState');//singleton
const FileDownloader = require('../FileDownloader'); const FileDownloader = require('../FileDownloader');
const FileDecompressor = require('../FileDecompressor'); const FileDecompressor = require('../FileDecompressor');
const BookConverter = require('./BookConverter'); const BookConverter = require('./BookConverter');
const RemoteWebDavStorage = require('../RemoteWebDavStorage');
const utils = require('../utils'); const utils = require('../utils');
const log = new (require('../AppLogger'))().log;//singleton const log = new (require('../AppLogger'))().log;//singleton
const cleanDirPeriod = 60*60*1000;//1 раз в час
const queue = new LimitedQueue(5, 100, 5*60*1000);//5 минут ожидание подвижек
let instance = null; let instance = null;
//singleton //singleton
@@ -24,12 +29,19 @@ class ReaderWorker {
fs.ensureDirSync(this.config.tempPublicDir); fs.ensureDirSync(this.config.tempPublicDir);
this.workerState = new WorkerState(); this.workerState = new WorkerState();
this.down = new FileDownloader(); this.down = new FileDownloader(config.maxUploadFileSize);
this.decomp = new FileDecompressor(); this.decomp = new FileDecompressor(2*config.maxUploadFileSize);
this.bookConverter = new BookConverter(this.config); this.bookConverter = new BookConverter(this.config);
this.periodicCleanDir(this.config.tempPublicDir, this.config.maxTempPublicDirSize, 60*60*1000);//1 раз в час this.remoteWebDavStorage = false;
this.periodicCleanDir(this.config.uploadDir, this.config.maxUploadPublicDirSize, 60*60*1000);//1 раз в час if (config.remoteWebDavStorage) {
this.remoteWebDavStorage = new RemoteWebDavStorage(
Object.assign({maxContentLength: config.maxUploadFileSize}, config.remoteWebDavStorage)
);
}
this.periodicCleanDir(this.config.tempPublicDir, this.config.maxTempPublicDirSize, cleanDirPeriod);
this.periodicCleanDir(this.config.uploadDir, this.config.maxUploadPublicDirSize, cleanDirPeriod);
instance = this; instance = this;
} }
@@ -39,22 +51,39 @@ class ReaderWorker {
async loadBook(opts, wState) { async loadBook(opts, wState) {
const url = opts.url; const url = opts.url;
let errMes = '';
let decompDir = ''; let decompDir = '';
let downloadedFilename = ''; let downloadedFilename = '';
let isUploaded = false; let isUploaded = false;
let convertFilename = ''; let convertFilename = '';
const overLoadMes = 'Слишком большая очередь загрузки. Пожалуйста, попробуйте позже.';
const overLoadErr = new Error(overLoadMes);
let q = null;
try { try {
wState.set({state: 'queue', step: 1, totalSteps: 1});
try {
let qSize = 0;
q = await queue.get((place) => {
wState.set({place, progress: (qSize ? Math.round((qSize - place)/qSize*100) : 0)});
if (!qSize)
qSize = place;
});
} catch (e) {
throw overLoadErr;
}
wState.set({state: 'download', step: 1, totalSteps: 3, url}); wState.set({state: 'download', step: 1, totalSteps: 3, url});
const tempFilename = utils.randomHexString(30); const tempFilename = utils.randomHexString(30);
const tempFilename2 = utils.randomHexString(30); const tempFilename2 = utils.randomHexString(30);
const decompDirname = utils.randomHexString(30); const decompDirname = utils.randomHexString(30);
//download or use uploaded
if (url.indexOf('file://') != 0) {//download if (url.indexOf('file://') != 0) {//download
const downdata = await this.down.load(url, (progress) => { const downdata = await this.down.load(url, (progress) => {
wState.set({progress}); wState.set({progress});
}); }, q.abort);
downloadedFilename = `${this.config.tempDownloadDir}/${tempFilename}`; downloadedFilename = `${this.config.tempDownloadDir}/${tempFilename}`;
await fs.writeFile(downloadedFilename, downdata); await fs.writeFile(downloadedFilename, downdata);
@@ -67,6 +96,10 @@ class ReaderWorker {
} }
wState.set({progress: 100}); wState.set({progress: 100});
if (q.abort())
throw overLoadErr;
q.resetTimeout();
//decompress //decompress
wState.set({state: 'decompress', step: 2, progress: 0}); wState.set({state: 'decompress', step: 2, progress: 0});
decompDir = `${this.config.tempDownloadDir}/${decompDirname}`; decompDir = `${this.config.tempDownloadDir}/${decompDirname}`;
@@ -79,27 +112,49 @@ class ReaderWorker {
} }
wState.set({progress: 100}); wState.set({progress: 100});
if (q.abort())
throw overLoadErr;
q.resetTimeout();
//конвертирование в fb2 //конвертирование в fb2
wState.set({state: 'convert', step: 3, progress: 0}); wState.set({state: 'convert', step: 3, progress: 0});
convertFilename = `${this.config.tempDownloadDir}/${tempFilename2}`; convertFilename = `${this.config.tempDownloadDir}/${tempFilename2}`;
await this.bookConverter.convertToFb2(decompFiles, convertFilename, opts, progress => { await this.bookConverter.convertToFb2(decompFiles, convertFilename, opts, progress => {
wState.set({progress}); wState.set({progress});
}); }, q.abort);
//сжимаем файл в tmp, если там уже нет с тем же именем-sha256 //сжимаем файл в tmp, если там уже нет с тем же именем-sha256
const compFilename = await this.decomp.gzipFileIfNotExists(convertFilename, `${this.config.tempPublicDir}`); const compFilename = await this.decomp.gzipFileIfNotExists(convertFilename, this.config.tempPublicDir);
const stat = await fs.stat(compFilename);
wState.set({progress: 100}); wState.set({progress: 100});
//finish //finish
const finishFilename = path.basename(compFilename); const finishFilename = path.basename(compFilename);
wState.finish({path: `/tmp/${finishFilename}`}); wState.finish({path: `/tmp/${finishFilename}`, size: stat.size});
//лениво сохраним compFilename в удаленном хранилище
if (this.remoteWebDavStorage) {
(async() => {
await utils.sleep(20*1000);
try {
//log(`remoteWebDavStorage.putFile ${path.basename(compFilename)}`);
await this.remoteWebDavStorage.putFile(compFilename);
} catch (e) {
log(LM_ERR, e.stack);
}
})();
}
} catch (e) { } catch (e) {
log(LM_ERR, e.stack); log(LM_ERR, e.stack);
wState.set({state: 'error', error: (errMes ? errMes : e.message)}); if (e.message == 'abort')
e.message = overLoadMes;
wState.set({state: 'error', error: e.message});
} finally { } finally {
//clean //clean
if (q)
q.ret();
if (decompDir) if (decompDir)
await fs.remove(decompDir); await fs.remove(decompDir);
if (downloadedFilename && !isUploaded) if (downloadedFilename && !isUploaded)
@@ -133,6 +188,41 @@ class ReaderWorker {
return `file://${hash}`; return `file://${hash}`;
} }
restoreCachedFile(filename) {
const workerId = this.workerState.generateWorkerId();
const wState = this.workerState.getControl(workerId);
wState.set({state: 'start'});
(async() => {
try {
wState.set({state: 'download', step: 1, totalSteps: 1, path: filename, progress: 0});
const basename = path.basename(filename);
const targetName = `${this.config.tempPublicDir}/${basename}`;
if (!await fs.pathExists(targetName)) {
let found = false;
if (this.remoteWebDavStorage) {
found = await this.remoteWebDavStorage.getFileSuccess(targetName);
}
if (!found) {
throw new Error('404 Файл не найден');
}
}
const stat = await fs.stat(targetName);
wState.finish({path: `/tmp/${basename}`, size: stat.size, progress: 100});
} catch (e) {
if (e.message.indexOf('404') < 0)
log(LM_ERR, e.stack);
wState.set({state: 'error', error: e.message});
}
})();
return workerId;
}
async periodicCleanDir(dir, maxSize, timeout) { async periodicCleanDir(dir, maxSize, timeout) {
try { try {
const list = await fs.readdir(dir); const list = await fs.readdir(dir);
@@ -153,7 +243,19 @@ class ReaderWorker {
let i = 0; let i = 0;
while (i < files.length && size > maxSize) { while (i < files.length && size > maxSize) {
const file = files[i]; const file = files[i];
await fs.remove(`${dir}/${file.name}`); const oldFile = `${dir}/${file.name}`;
//отправляем только this.config.tempPublicDir
//TODO: убрать в будущем, т.к. уже делается ленивое сохранение compFilename в удаленном хранилище
if (this.remoteWebDavStorage && dir === this.config.tempPublicDir) {
try {
//log(`remoteWebDavStorage.putFile ${path.basename(oldFile)}`);
await this.remoteWebDavStorage.putFile(oldFile);
} catch (e) {
log(LM_ERR, e.stack);
}
}
await fs.remove(oldFile);
size -= file.stat.size; size -= file.stat.size;
i++; i++;
} }

View File

@@ -0,0 +1,107 @@
const fs = require('fs-extra');
const path = require('path');
const { createClient } = require('webdav');
class RemoteWebDavStorage {
constructor(config) {
this.config = Object.assign({}, config);
this.config.maxContentLength = this.config.maxContentLength || 10*1024*1024;
this.wdc = createClient(config.url, this.config);
}
_convertStat(data) {
return {
isDirectory: function() {
return data.type === "directory";
},
isFile: function() {
return data.type === "file";
},
mtime: (new Date(data.lastmod)).getTime(),
name: data.basename,
size: data.size || 0
};
}
async stat(filename) {
const stat = await this.wdc.stat(filename);
return this._convertStat(stat);
}
async writeFile(filename, data) {
return await this.wdc.putFileContents(filename, data, { maxContentLength: this.config.maxContentLength })
}
async unlink(filename) {
return await this.wdc.deleteFile(filename);
}
async readFile(filename) {
return await this.wdc.getFileContents(filename, { maxContentLength: this.config.maxContentLength })
}
async mkdir(dirname) {
return await this.wdc.createDirectory(dirname);
}
async putFile(filename) {
if (!await fs.pathExists(filename)) {
throw new Error(`File not found: ${filename}`);
}
const base = path.basename(filename);
let remoteFilename = `/${base}`;
if (base.length > 3) {
const remoteDir = `/${base.substr(0, 3)}`;
try {
await this.mkdir(remoteDir);
} catch (e) {
//
}
remoteFilename = `${remoteDir}/${base}`;
}
try {
const localStat = await fs.stat(filename);
const remoteStat = await this.stat(remoteFilename);
if (remoteStat.isFile && localStat.size == remoteStat.size) {
return;
}
await this.unlink(remoteFilename);
} catch (e) {
//
}
const data = await fs.readFile(filename);
await this.writeFile(remoteFilename, data);
}
async getFile(filename) {
if (await fs.pathExists(filename)) {
return;
}
const base = path.basename(filename);
let remoteFilename = `/${base}`;
if (base.length > 3) {
remoteFilename = `/${base.substr(0, 3)}/${base}`;
}
const data = await this.readFile(remoteFilename);
await fs.writeFile(filename, data);
}
async getFileSuccess(filename) {
try {
await this.getFile(filename);
return true;
} catch (e) {
//
}
return false;
}
}
module.exports = RemoteWebDavStorage;

View File

@@ -52,7 +52,7 @@ class ZipStreamer {
})().catch(reject); }); })().catch(reject); });
} }
unpack(zipFile, outputDir, entryCallback) { unpack(zipFile, outputDir, entryCallback, limitFileSize = 0) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
entryCallback = (entryCallback ? entryCallback : () => {}); entryCallback = (entryCallback ? entryCallback : () => {});
const unzip = new unzipStream({file: zipFile}); const unzip = new unzipStream({file: zipFile});
@@ -67,6 +67,15 @@ class ZipStreamer {
}); });
unzip.on('ready', () => { unzip.on('ready', () => {
if (limitFileSize) {
for (const entry of Object.values(unzip.entries())) {
if (!entry.isDirectory && entry.size > limitFileSize) {
reject('Файл слишком большой');
return;
}
}
}
unzip.extract(null, outputDir, (err) => { unzip.extract(null, outputDir, (err) => {
if (err) reject(err); if (err) reject(err);
unzip.close(); unzip.close();

View File

@@ -37,8 +37,8 @@ async function touchFile(filename) {
} }
function spawnProcess(cmd, opts) { function spawnProcess(cmd, opts) {
let {args, killAfter, onData} = opts; let {args, killAfter, onData, abort} = opts;
killAfter = (killAfter ? killAfter : 120*1000); killAfter = (killAfter ? killAfter : 120);//seconds
onData = (onData ? onData : () => {}); onData = (onData ? onData : () => {});
args = (args ? args : []); args = (args ? args : []);
@@ -67,10 +67,18 @@ function spawnProcess(cmd, opts) {
reject({status: 'error', error, stdout, stderr}); reject({status: 'error', error, stdout, stderr});
}); });
await sleep(killAfter); while (!resolved) {
if (!resolved) { await sleep(1000);
process.kill(proc.pid); killAfter -= 1;
reject({status: 'killed', stdout, stderr}); if (killAfter <= 0 || (abort && abort())) {
process.kill(proc.pid);
if (killAfter <= 0) {
reject({status: 'killed', stdout, stderr});
} else {
reject({status: 'abort', stdout, stderr});
}
break;
}
} }
})().catch(reject); }); })().catch(reject); });
} }

View File

@@ -14,7 +14,6 @@ class SqliteConnectionPool {
if (!Number.isInteger(connCount) || connCount <= 0) if (!Number.isInteger(connCount) || connCount <= 0)
return; return;
this.connections = []; this.connections = [];
this.taken = new Set();
this.freed = new Set(); this.freed = new Set();
for (let i = 0; i < connCount; i++) { for (let i = 0; i < connCount; i++) {
@@ -22,7 +21,6 @@ class SqliteConnectionPool {
client.configure('busyTimeout', 10000); //ms client.configure('busyTimeout', 10000); //ms
client.ret = () => { client.ret = () => {
this.taken.delete(i);
this.freed.add(i); this.freed.add(i);
}; };
@@ -52,7 +50,6 @@ class SqliteConnectionPool {
} }
this.freed.delete(freeConnIndex); this.freed.delete(freeConnIndex);
this.taken.add(freeConnIndex);
return this.connections[freeConnIndex]; return this.connections[freeConnIndex];
} }

View File

@@ -4,6 +4,8 @@ const path = require('path');
const argv = require('minimist')(process.argv.slice(2)); const argv = require('minimist')(process.argv.slice(2));
const express = require('express'); const express = require('express');
const compression = require('compression'); const compression = require('compression');
const http = require('http');
const WebSocket = require ('ws');
async function init() { async function init() {
//config //config
@@ -46,10 +48,13 @@ async function main() {
const config = new (require('./config'))().config;//singleton const config = new (require('./config'))().config;//singleton
//servers //servers
for (let server of config.servers) { for (let serverCfg of config.servers) {
if (server.mode !== 'none') { if (serverCfg.mode !== 'none') {
const app = express(); const app = express();
const serverConfig = Object.assign({}, config, server); const server = http.createServer(app);
const wss = new WebSocket.Server({ server, maxPayload: 10*1024*1024 });
const serverConfig = Object.assign({}, config, serverCfg);
let devModule = undefined; let devModule = undefined;
if (serverConfig.branch == 'development') { if (serverConfig.branch == 'development') {
@@ -73,7 +78,7 @@ async function main() {
} }
})); }));
require('./routes').initRoutes(app, serverConfig); require('./routes').initRoutes(app, wss, serverConfig);
if (devModule) { if (devModule) {
devModule.logErrors(app); devModule.logErrors(app);
@@ -84,7 +89,7 @@ async function main() {
}); });
} }
app.listen(serverConfig.port, serverConfig.ip, function() { server.listen(serverConfig.port, serverConfig.ip, function() {
log(`Server-${serverConfig.serverName} is ready on ${serverConfig.ip}:${serverConfig.port}, mode: ${serverConfig.mode}`); log(`Server-${serverConfig.serverName} is ready on ${serverConfig.ip}:${serverConfig.port}, mode: ${serverConfig.mode}`);
}); });
} }

View File

@@ -2,10 +2,11 @@ const c = require('./controllers');
const utils = require('./core/utils'); const utils = require('./core/utils');
const multer = require('multer'); const multer = require('multer');
function initRoutes(app, config) { function initRoutes(app, wss, config) {
const misc = new c.MiscController(config); const misc = new c.MiscController(config);
const reader = new c.ReaderController(config); const reader = new c.ReaderController(config);
const worker = new c.WorkerController(config); const worker = new c.WorkerController(config);
new c.WebSocketController(wss, config);
//access //access
const [aAll, aNormal, aSite, aReader, aOmnireader] = // eslint-disable-line no-unused-vars const [aAll, aNormal, aSite, aReader, aOmnireader] = // eslint-disable-line no-unused-vars
@@ -28,7 +29,9 @@ function initRoutes(app, config) {
['POST', '/api/reader/load-book', reader.loadBook.bind(reader), [aAll], {}], ['POST', '/api/reader/load-book', reader.loadBook.bind(reader), [aAll], {}],
['POST', '/api/reader/storage', reader.storage.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/reader/upload-file', [upload.single('file'), reader.uploadFile.bind(reader)], [aAll], {}],
['POST', '/api/reader/restore-cached-file', reader.restoreCachedFile.bind(reader), [aAll], {}],
['POST', '/api/worker/get-state', worker.getState.bind(worker), [aAll], {}], ['POST', '/api/worker/get-state', worker.getState.bind(worker), [aAll], {}],
['POST', '/api/worker/get-state-finish', worker.getStateFinish.bind(worker), [aAll], {}],
]; ];
//to app //to app