flamerest 1.0.125 → 1.0.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/REST.d.ts +301 -0
- package/REST.js +810 -0
- package/REST.ts +162 -17
- package/package.json +8 -6
- package/tsconfig.json +0 -1
package/REST.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
export class FLAMEREST {
|
|
2
|
+
/**
|
|
3
|
+
* Конструктор класса FLAMEREST
|
|
4
|
+
* @param {string} server_address Адрес сервера (опционально)
|
|
5
|
+
* @param {string} localhost_endpoint Альтернативный адрес для localhost (опционально)
|
|
6
|
+
* @param {Function} unauthorized_callback Колбек для обработки 401 ошибок (опционально)
|
|
7
|
+
* @param {string} version Версия API (по умолчанию 'v1')
|
|
8
|
+
* @param {Function} maintenance_callback Колбек для обработки 503 ошибок (опционально)
|
|
9
|
+
*/
|
|
10
|
+
constructor(server_address, localhost_endpoint, unauthorized_callback, version, maintenance_callback) {
|
|
11
|
+
/**
|
|
12
|
+
* Стандартное число запросов на страницу
|
|
13
|
+
*/
|
|
14
|
+
this.perPageDefault = 20;
|
|
15
|
+
/**
|
|
16
|
+
* Токен приложения конкретного клиента для отправки пуш уведомлений именно ему
|
|
17
|
+
*/
|
|
18
|
+
this.pushNotificationToken = null;
|
|
19
|
+
/**
|
|
20
|
+
* Версия кода клиента (отправляется в заголовке каждого запроса).
|
|
21
|
+
* Можно установить извне: REST.VERSION_CODE = "1.2.3"
|
|
22
|
+
*/
|
|
23
|
+
this.VERSION_CODE = "1.0.0";
|
|
24
|
+
/**
|
|
25
|
+
* Если авторизация по токену, то он сюда подставляется
|
|
26
|
+
*/
|
|
27
|
+
this.isAuthByJWTQuery = true;
|
|
28
|
+
/**
|
|
29
|
+
* Режим авторизации: Bearer|Link
|
|
30
|
+
*/
|
|
31
|
+
this.authMode = "Bearer";
|
|
32
|
+
this.token = null;
|
|
33
|
+
if (typeof window === 'undefined') {
|
|
34
|
+
// SSR: используем предоставленный адрес или дефолтный
|
|
35
|
+
this.SERVER = server_address ?? "http://localhost/";
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Client: определяем адрес по window.location или из параметра
|
|
39
|
+
if (server_address === undefined) {
|
|
40
|
+
this.SERVER = window.location.protocol + "//" + window.location.host;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
this.SERVER = server_address;
|
|
44
|
+
}
|
|
45
|
+
if (window.location.hostname === 'localhost' && localhost_endpoint !== undefined) {
|
|
46
|
+
this.SERVER = localhost_endpoint;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Убираем слэш в конце, если он есть
|
|
50
|
+
this.SERVER = this.SERVER.endsWith('/') ? this.SERVER.substring(0, this.SERVER.length - 1) : this.SERVER;
|
|
51
|
+
this.unauthorized_callback = unauthorized_callback;
|
|
52
|
+
this.maintenance_callback = maintenance_callback;
|
|
53
|
+
this.version = version ?? 'v1';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Установка плагина для Vue
|
|
57
|
+
* @param Vue
|
|
58
|
+
* @param options
|
|
59
|
+
*/
|
|
60
|
+
install(Vue, options) {
|
|
61
|
+
if (typeof window !== 'undefined') {
|
|
62
|
+
window.REST = this;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
*
|
|
67
|
+
* @param {string} url Адрес
|
|
68
|
+
* @param {object|string|FormData} params Параметры, которые надо передать, могут быть в виде объекта или строки
|
|
69
|
+
* @param {string} type Тип
|
|
70
|
+
* @param {string} responseType Тип ответа: json или blob
|
|
71
|
+
* @param {boolean} isNeedToken нужен ли токен для запроса
|
|
72
|
+
* @param {Object} customHeaders объект с доп заголовками, которые надо включить в запрос
|
|
73
|
+
*/
|
|
74
|
+
async request(url, params, type = 'GET', responseType = 'json', isNeedToken = true, customHeaders = {}) {
|
|
75
|
+
// Подставляем сервер автоматом если URL не содержит протокол
|
|
76
|
+
// Не трогаем URL с явным протоколом (http://, https://) или протокол-независимые (//)
|
|
77
|
+
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('//')) {
|
|
78
|
+
// Убираем начальный "/" если он есть, т.к. SERVER уже без слэша в конце
|
|
79
|
+
if (url[0] === '/') {
|
|
80
|
+
url = url.substring(1);
|
|
81
|
+
}
|
|
82
|
+
// Добавляем SERVER с разделителем "/"
|
|
83
|
+
url = this.SERVER + '/' + url;
|
|
84
|
+
}
|
|
85
|
+
// Создаём подпись запроса до его модификации
|
|
86
|
+
const SIGN = await this._generateRequestSignature(url, params);
|
|
87
|
+
// Нормализуем параметры, если они есть
|
|
88
|
+
if (typeof params === "object" && params !== null) {
|
|
89
|
+
if (!(params instanceof FormData)) {
|
|
90
|
+
params = JSON.stringify(params);
|
|
91
|
+
}
|
|
92
|
+
if (type === 'GET') {
|
|
93
|
+
type = 'POST';
|
|
94
|
+
}
|
|
95
|
+
responseType = 'json';
|
|
96
|
+
}
|
|
97
|
+
const that = this;
|
|
98
|
+
// Фетч поддерживается - получаем через него, это быстрее
|
|
99
|
+
if (typeof fetch === "function") {
|
|
100
|
+
return new Promise(async (resolve, reject) => {
|
|
101
|
+
try {
|
|
102
|
+
// Авторизация
|
|
103
|
+
if (this.isAuthByJWTQuery && isNeedToken === true && this.token !== null && this.token !== undefined && this.token !== 'undefined') {
|
|
104
|
+
switch (this.authMode) {
|
|
105
|
+
case 'Link':
|
|
106
|
+
url = url + (url.indexOf("?") === -1 ? "?" : "&") + "access-token=" + this.token;
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
Object.assign(customHeaders, { 'Authorization': 'Bearer ' + this.token });
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Уникальный user_hash (только на клиенте)
|
|
114
|
+
let user_hash = null;
|
|
115
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
116
|
+
user_hash = window.localStorage.getItem('user_hash');
|
|
117
|
+
if (user_hash) {
|
|
118
|
+
Object.assign(customHeaders, { udata: user_hash });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Тело запроса
|
|
122
|
+
const headers = { ...customHeaders };
|
|
123
|
+
if (!(params instanceof FormData)) {
|
|
124
|
+
headers['Content-type'] = 'application/json; charset=utf-8';
|
|
125
|
+
}
|
|
126
|
+
// Версия кода клиента — уходит с каждым запросом
|
|
127
|
+
headers['version'] = this.VERSION_CODE;
|
|
128
|
+
const requestBody = {
|
|
129
|
+
method: type,
|
|
130
|
+
mode: 'cors',
|
|
131
|
+
headers: headers
|
|
132
|
+
};
|
|
133
|
+
if (type !== 'GET') {
|
|
134
|
+
// Принимает и formData тоже и чистую json строку
|
|
135
|
+
requestBody.body = params;
|
|
136
|
+
}
|
|
137
|
+
// Добавляем подпись в заголовки
|
|
138
|
+
requestBody.headers['sign'] = SIGN;
|
|
139
|
+
// Делаем запрос
|
|
140
|
+
const response = await fetch(url, requestBody);
|
|
141
|
+
// Тело ответа формируется в два этапа: сперва заголовки, затем ответ
|
|
142
|
+
const ResolveBody = {
|
|
143
|
+
status: response.status,
|
|
144
|
+
ok: response.ok
|
|
145
|
+
};
|
|
146
|
+
// Ответ с ошибкой
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
// Тело ошибки
|
|
149
|
+
ResolveBody.message = response.statusText;
|
|
150
|
+
try {
|
|
151
|
+
ResolveBody.errors = await response.json();
|
|
152
|
+
// Ошибки
|
|
153
|
+
switch (ResolveBody.status) {
|
|
154
|
+
// Ошибка валидации: Собираем все ошибки полей
|
|
155
|
+
case 422:
|
|
156
|
+
const Errs = {};
|
|
157
|
+
for (const err of ResolveBody.errors) {
|
|
158
|
+
Errs[err['field']] = Errs[err['field']] === undefined ? err['message'] : Errs[err['field']] + ". " + err['message'];
|
|
159
|
+
}
|
|
160
|
+
ResolveBody.errors = Errs;
|
|
161
|
+
break;
|
|
162
|
+
case 401:
|
|
163
|
+
if (typeof this.unauthorized_callback === 'function') {
|
|
164
|
+
this.unauthorized_callback();
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case 503:
|
|
168
|
+
// Обработка режима обслуживания (maintenance)
|
|
169
|
+
let maintenanceMessage = undefined;
|
|
170
|
+
if (ResolveBody.errors && typeof ResolveBody.errors === 'object' && 'error' in ResolveBody.errors) {
|
|
171
|
+
maintenanceMessage = ResolveBody.errors.error;
|
|
172
|
+
}
|
|
173
|
+
if (typeof this.maintenance_callback === 'function') {
|
|
174
|
+
this.maintenance_callback(maintenanceMessage);
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (exjson) {
|
|
180
|
+
ResolveBody.errors = await response.text();
|
|
181
|
+
}
|
|
182
|
+
// Рапортуем об ошибке
|
|
183
|
+
console.error('Ошибка загрузки [' + response.status + '] ' + url + ": " + response.statusText, ResolveBody, ResolveBody.errors);
|
|
184
|
+
resolve(ResolveBody);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Загрузка успешна
|
|
188
|
+
// Если в заголовках указана паджинация
|
|
189
|
+
let pages = undefined;
|
|
190
|
+
if (response.headers.get('X-Pagination-Current-Page') !== null) {
|
|
191
|
+
pages = {
|
|
192
|
+
page: parseInt(response.headers.get('X-Pagination-Current-Page')),
|
|
193
|
+
perPage: parseInt(response.headers.get('X-Pagination-Per-Page')),
|
|
194
|
+
count: parseInt(response.headers.get('X-Pagination-Page-Count')),
|
|
195
|
+
total: parseInt(response.headers.get('X-Pagination-Total-Count')),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// Заполняем тело ответа заголовками
|
|
199
|
+
ResolveBody.type = "json";
|
|
200
|
+
ResolveBody.data = {};
|
|
201
|
+
ResolveBody.pages = pages;
|
|
202
|
+
// Получаем тело ответа
|
|
203
|
+
switch (responseType) {
|
|
204
|
+
case 'json':
|
|
205
|
+
ResolveBody.data = await response.text();
|
|
206
|
+
break;
|
|
207
|
+
case 'blob': {
|
|
208
|
+
// Записываем имя файла и mime-тип
|
|
209
|
+
ResolveBody.filename = 'file'; // TODO: response.headers.get('content-disposition').split('filename=')[1];
|
|
210
|
+
ResolveBody.MimeType = response.headers.get('content-Type');
|
|
211
|
+
ResolveBody.data = await response.blob();
|
|
212
|
+
// Если ответ в виде блоба, сразу его отдаём без декодировки
|
|
213
|
+
resolve(ResolveBody);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Декодируем тело ответа, если оно есть
|
|
218
|
+
if (ResolveBody.data === undefined) {
|
|
219
|
+
ResolveBody.errors = ["Принятый ответ пуст"];
|
|
220
|
+
console.error("Принятый ответ пуст", ResolveBody);
|
|
221
|
+
resolve(ResolveBody);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
;
|
|
225
|
+
// Пустой ответ конвертируем в валидный пустой объект
|
|
226
|
+
if (ResolveBody.data === "")
|
|
227
|
+
ResolveBody.data = "{}";
|
|
228
|
+
// Декодируем
|
|
229
|
+
try {
|
|
230
|
+
ResolveBody.data = JSON.parse(ResolveBody.data);
|
|
231
|
+
}
|
|
232
|
+
catch (ex) {
|
|
233
|
+
ResolveBody.errors = ["Ошибка декодирования"];
|
|
234
|
+
console.error("Ошибка декодирования", ResolveBody);
|
|
235
|
+
reject(ResolveBody);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Если пришёл ответ: неавторизовано, и указан коллбек авторизации - запускаем его
|
|
239
|
+
if (ResolveBody.data.Auth === false) {
|
|
240
|
+
if (that.unauthorized_callback !== undefined) {
|
|
241
|
+
that.unauthorized_callback();
|
|
242
|
+
resolve(ResolveBody);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Возвращаем успешную загрузку
|
|
247
|
+
resolve(ResolveBody);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
// Ошибка загрузки любого типа
|
|
251
|
+
// TODO: на этом этапе стоит сделать, чтобы он пробовал повторить запрос, если это GET
|
|
252
|
+
if (typeof err !== 'object' || err.message === undefined) {
|
|
253
|
+
err = {
|
|
254
|
+
status: 0,
|
|
255
|
+
message: '',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (typeof err.body === 'object') {
|
|
259
|
+
err.body = await err.body;
|
|
260
|
+
}
|
|
261
|
+
console.error('Ошибка загрузки [' + 0 + '] ' + url + ": " + err.message);
|
|
262
|
+
reject(err);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Фетч не поддерживается (старые браузеры или окружения) - возвращаем промисифицированный XHR
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const xhr = new XMLHttpRequest();
|
|
270
|
+
xhr.open(type, url, true);
|
|
271
|
+
xhr.responseType = 'json';
|
|
272
|
+
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
|
|
273
|
+
// Версия кода клиента — уходит с каждым запросом (fallback без fetch)
|
|
274
|
+
xhr.setRequestHeader('version', this.VERSION_CODE);
|
|
275
|
+
xhr.withCredentials = false;
|
|
276
|
+
xhr.send(params);
|
|
277
|
+
xhr.onload = function () {
|
|
278
|
+
if (xhr.status !== 200) {
|
|
279
|
+
console.error('Ошибка загрузки [' + xhr.status + '] ' + url + ": " + xhr.statusText);
|
|
280
|
+
return reject({
|
|
281
|
+
status: xhr.status,
|
|
282
|
+
message: xhr.statusText
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
let pages = undefined;
|
|
286
|
+
if (xhr.getResponseHeader('X-Pagination-Current-Page') !== null) {
|
|
287
|
+
pages = {
|
|
288
|
+
page: parseInt(xhr.getResponseHeader('X-Pagination-Current-Page')),
|
|
289
|
+
perPage: parseInt(xhr.getResponseHeader('X-Pagination-Per-Page')),
|
|
290
|
+
count: parseInt(xhr.getResponseHeader('X-Pagination-Page-Count')),
|
|
291
|
+
total: parseInt(xhr.getResponseHeader('X-Pagination-Total-Count')),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return resolve({
|
|
295
|
+
status: xhr.status,
|
|
296
|
+
type: xhr.responseType,
|
|
297
|
+
data: xhr.response,
|
|
298
|
+
pages: pages,
|
|
299
|
+
ok: true,
|
|
300
|
+
errors: undefined,
|
|
301
|
+
message: ''
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
xhr.onerror = function () {
|
|
305
|
+
console.error('Ошибка загрузки [' + 0 + '] ' + url + ": Нет соединения с сервером");
|
|
306
|
+
return reject({
|
|
307
|
+
status: 0,
|
|
308
|
+
message: "Нет соединения с сервером"
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Получить выборку из таблицы через REST
|
|
316
|
+
* @param {string} table
|
|
317
|
+
* @param {object | string | null} where Позволяет делать выборку из связанных таблиц, надо только их указать через название таблицы sites.id=5, и указать колонку в expand
|
|
318
|
+
* @param {object | Array<string> | null} extfields
|
|
319
|
+
* @param {object | Array<string> | string | null} fields
|
|
320
|
+
* @param {object | Array<string> | string | null} sortfields
|
|
321
|
+
* @param {number | undefined} page
|
|
322
|
+
* @param {number | undefined} perPage
|
|
323
|
+
* @param {boolean | undefined} RemoveDuplicates
|
|
324
|
+
* @param {any} titles Это чтобы мы могли контроллить какие названия полей мы будет загружать при экспорте, чтобы они были как в таблице
|
|
325
|
+
* @param {any} tree дерево
|
|
326
|
+
* @param {any} params Доп параметры для кастомизации запроса на беке
|
|
327
|
+
* @param {any} exportData имя файла для экспорта
|
|
328
|
+
* @return Promise<Rows<T>>
|
|
329
|
+
*/
|
|
330
|
+
get(table, where, extfields, fields, sortfields, page, perPage, RemoveDuplicates, format, titles, tree, params, exportData) {
|
|
331
|
+
// Нормализуем имена таблиц
|
|
332
|
+
table = table.replace(/_/g, "");
|
|
333
|
+
let responseType = "json";
|
|
334
|
+
// Генерим запрос
|
|
335
|
+
const query = this.SERVER + '/api/' + this.version + '/' + table;
|
|
336
|
+
const json = {};
|
|
337
|
+
// Генерим условия
|
|
338
|
+
if (where !== undefined && where !== null)
|
|
339
|
+
json.where = where;
|
|
340
|
+
if (tree !== undefined && tree !== null)
|
|
341
|
+
json.tree = tree;
|
|
342
|
+
if (fields !== undefined && fields !== null)
|
|
343
|
+
json.fields = fields;
|
|
344
|
+
if (sortfields !== undefined && sortfields !== null)
|
|
345
|
+
json.sort = sortfields;
|
|
346
|
+
if (extfields !== undefined && extfields !== null)
|
|
347
|
+
json.extfields = extfields;
|
|
348
|
+
if (params !== undefined && params !== null)
|
|
349
|
+
json.params = params;
|
|
350
|
+
if (RemoveDuplicates !== undefined && RemoveDuplicates !== null)
|
|
351
|
+
json.RemoveDuplicates = true;
|
|
352
|
+
if (titles !== undefined && titles !== null)
|
|
353
|
+
json.titles = titles;
|
|
354
|
+
// экспорт
|
|
355
|
+
if (exportData !== undefined && exportData !== null) {
|
|
356
|
+
json.export = {
|
|
357
|
+
format: exportData.format ?? 'xlsx',
|
|
358
|
+
titles: exportData.titles ?? [],
|
|
359
|
+
filename: exportData.filename ?? 'export_' + Date.now() + ".xlsx",
|
|
360
|
+
};
|
|
361
|
+
responseType = "blob";
|
|
362
|
+
}
|
|
363
|
+
// Страницы
|
|
364
|
+
json['per-page'] = perPage === undefined ? this.perPageDefault : perPage;
|
|
365
|
+
json['page'] = page === undefined ? 1 : page;
|
|
366
|
+
return this.request(query, JSON.stringify(json), 'POST', responseType);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Получить все записи по запросу [постранично]
|
|
370
|
+
* @param {string} table
|
|
371
|
+
* @param {object} params
|
|
372
|
+
* @returns {Promise<Rows<T>>}
|
|
373
|
+
*/
|
|
374
|
+
all(table, params) {
|
|
375
|
+
return this.get(table, params?.where, params?.extfields, params?.fields, params?.sort, params?.page, params?.perPage, undefined, undefined, undefined, params?.tree, params?.params, params?.export);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Получить одну запись по ID или по условию выборки [первая запись]
|
|
379
|
+
* @param {string} table
|
|
380
|
+
* @param {number|string|object} IDOrWhere
|
|
381
|
+
* @param {object|Array} extfields
|
|
382
|
+
* @param {object|Array} fields
|
|
383
|
+
* @param {string} primaryKeyName если указан ID, то указать название первичного ключа, если от id он отличается
|
|
384
|
+
*/
|
|
385
|
+
async one(table, IDOrWhere, extfields = null, fields = null, primaryKeyName = 'id') {
|
|
386
|
+
let where = {};
|
|
387
|
+
if (typeof IDOrWhere === 'string' || typeof IDOrWhere === 'number')
|
|
388
|
+
where = { [primaryKeyName]: IDOrWhere };
|
|
389
|
+
else if (typeof IDOrWhere === 'object')
|
|
390
|
+
where = IDOrWhere;
|
|
391
|
+
else
|
|
392
|
+
throw new Error("Нужно передавать ID или объект");
|
|
393
|
+
const resp = await this.get(table, where, extfields, fields, null, 1, 1);
|
|
394
|
+
if (resp.errors)
|
|
395
|
+
return Promise.reject(resp);
|
|
396
|
+
if (!resp.data || resp.data.length === 0)
|
|
397
|
+
return null;
|
|
398
|
+
return resp.data[0];
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Создать новую запись
|
|
402
|
+
* @param {string} table
|
|
403
|
+
* @param {object} values
|
|
404
|
+
* @param {number|string|null} appendTo
|
|
405
|
+
* @param {number|string|null} insertAfter
|
|
406
|
+
* @param {boolean|null} insertFirst
|
|
407
|
+
*/
|
|
408
|
+
async create(table, values, appendTo = null, insertAfter = null, insertFirst = null) {
|
|
409
|
+
// Нормализуем имена таблиц
|
|
410
|
+
table = table.replace(/_/g, "");
|
|
411
|
+
// Подготовить значения
|
|
412
|
+
if (!(values instanceof FormData)) {
|
|
413
|
+
await this.prepare(values);
|
|
414
|
+
}
|
|
415
|
+
return this.request(this.SERVER + '/api/' + this.version + '/' + table + '/create?'
|
|
416
|
+
+ (appendTo !== null ? '&appendTo=' + appendTo : '')
|
|
417
|
+
+ (insertAfter !== null ? '&insertAfter=' + insertAfter : '')
|
|
418
|
+
+ (insertFirst !== null ? '&insertFirst=' + insertFirst : ''), (values instanceof FormData ? values : JSON.stringify(values)), 'POST');
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Удалить запись
|
|
422
|
+
* @param {string} table
|
|
423
|
+
* @param {number|string} id
|
|
424
|
+
* @param {object|null} byFields Если указан, удаляет по этим параметрам
|
|
425
|
+
*/
|
|
426
|
+
async remove(table, id = 0, byFields = null) {
|
|
427
|
+
// Нормализуем имена таблиц
|
|
428
|
+
table = table.replace(/_/g, "");
|
|
429
|
+
let params = {};
|
|
430
|
+
if (byFields instanceof Object)
|
|
431
|
+
params = byFields;
|
|
432
|
+
const resp = await this.request(this.SERVER + '/api/' + this.version + '/' + table + '/delete?id=' + id, JSON.stringify(params), 'DELETE');
|
|
433
|
+
if (resp.status === 204)
|
|
434
|
+
return true;
|
|
435
|
+
return resp;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Редактировать значения
|
|
439
|
+
* @param {string} table
|
|
440
|
+
* @param {number|string} ID
|
|
441
|
+
* @param {object} values
|
|
442
|
+
* @param {number|string|null} appendTo
|
|
443
|
+
* @param {number|string|null} insertAfter
|
|
444
|
+
* @param {boolean|null} insertFirst
|
|
445
|
+
*/
|
|
446
|
+
async edit(table, ID, values, appendTo = null, insertAfter = null, insertFirst = null) {
|
|
447
|
+
// Нормализуем имена таблиц
|
|
448
|
+
table = table.replace(/_/g, "");
|
|
449
|
+
// Подготовить значения
|
|
450
|
+
if (!(values instanceof FormData)) {
|
|
451
|
+
await this.prepare(values);
|
|
452
|
+
}
|
|
453
|
+
return this.request(this.SERVER + '/api/' + this.version + '/' + table + '/update?id=' + ID
|
|
454
|
+
+ (appendTo !== null ? '&appendTo=' + appendTo : '')
|
|
455
|
+
+ (insertAfter !== null ? '&insertAfter=' + insertAfter : '')
|
|
456
|
+
+ (insertFirst !== null ? '&insertFirst=' + insertFirst : ''), (values instanceof FormData ? values : JSON.stringify(values)), 'POST');
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Получить схемы всех таблиц
|
|
460
|
+
*/
|
|
461
|
+
getCRUDInfo() {
|
|
462
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
463
|
+
const cachedSchema = window.sessionStorage.getItem("crudschema");
|
|
464
|
+
if (cachedSchema) {
|
|
465
|
+
return new Promise((resolve) => { resolve(JSON.parse(cachedSchema)); });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return this.request(this.SERVER + '/site/crudschema', {}, 'GET')
|
|
469
|
+
.then(res => {
|
|
470
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
471
|
+
// Кешируем схему в браузере на время текущей сессии (в пределах ОДНОЙ вкладки)
|
|
472
|
+
window.sessionStorage.setItem("crudschema", JSON.stringify(res));
|
|
473
|
+
}
|
|
474
|
+
return res;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Авторизоваться
|
|
479
|
+
* @param {string|undefined} username
|
|
480
|
+
* @param {string|undefined} password
|
|
481
|
+
* @param {object|string|null} pushNotificationToken
|
|
482
|
+
*/
|
|
483
|
+
async auth(username, password, pushNotificationToken) {
|
|
484
|
+
let resp;
|
|
485
|
+
if (this.token && !username) {
|
|
486
|
+
resp = await this.request(this.SERVER + '/auth/auth', JSON.stringify({}), 'POST', 'json', true);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
resp = await this.request(this.SERVER + '/auth/auth', JSON.stringify({ login: username, password: password, pushNotificationToken: (pushNotificationToken ?? this.pushNotificationToken ?? null) }), 'POST', 'json', false);
|
|
490
|
+
}
|
|
491
|
+
if (resp.errors || !resp.data) {
|
|
492
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
493
|
+
}
|
|
494
|
+
// после успешной авторизации устанавливаем токен
|
|
495
|
+
if (typeof resp.data.token === 'string')
|
|
496
|
+
this.token = resp.data.token;
|
|
497
|
+
// Сохраняем хеш юзера автоматически (только на клиенте)
|
|
498
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
499
|
+
const user_hash = window.localStorage.getItem('user_hash');
|
|
500
|
+
if (resp?.data?.User?.user_hash && resp.data.User.user_hash !== user_hash) {
|
|
501
|
+
window.localStorage.setItem('user_hash', resp.data.User.user_hash);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return resp.data;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Зарегистрироваться с этим логином и паролем
|
|
508
|
+
* @param {string|null} email
|
|
509
|
+
* @param {string|null} username
|
|
510
|
+
* @param {string} password
|
|
511
|
+
* @param {string|null} name
|
|
512
|
+
* @param {object|string|null} pushNotificationToken
|
|
513
|
+
* @param {any} data
|
|
514
|
+
*/
|
|
515
|
+
async signup(email, username, password, name = null, pushNotificationToken = null, data = null) {
|
|
516
|
+
const resp = await this.request(this.SERVER + '/auth/signup', JSON.stringify({
|
|
517
|
+
login: username, email: email, password: password,
|
|
518
|
+
name: name, data: data, pushNotificationToken: (pushNotificationToken ?? this.pushNotificationToken ?? null)
|
|
519
|
+
}), 'POST', 'json', false);
|
|
520
|
+
if (resp.errors || !resp.data) {
|
|
521
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
522
|
+
}
|
|
523
|
+
// после успешной авторизации устанавливаем токен
|
|
524
|
+
if (typeof resp.data.token === 'string')
|
|
525
|
+
this.token = resp.data.token;
|
|
526
|
+
return resp.data;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Выйти из системы
|
|
530
|
+
*/
|
|
531
|
+
logout() {
|
|
532
|
+
return this.request(this.SERVER + '/auth/logout', '{}', 'POST');
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Восстановление пароля
|
|
536
|
+
* Запрос на восстановление пароля
|
|
537
|
+
* @param {*} email
|
|
538
|
+
* @returns
|
|
539
|
+
*/
|
|
540
|
+
async ResetPasswordRequest(email) {
|
|
541
|
+
const resp = await this.request(this.SERVER + '/auth/resetpasswordrequest', JSON.stringify({ email: email }), 'POST', 'json', false);
|
|
542
|
+
if (resp.errors || !resp.data)
|
|
543
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
544
|
+
return resp.data;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Восстановление пароля
|
|
548
|
+
* Проверка токена восстановления
|
|
549
|
+
* @param {*} token токен подтверждения
|
|
550
|
+
* @returns
|
|
551
|
+
*/
|
|
552
|
+
async ResetPasswordTokenCheck(token) {
|
|
553
|
+
const resp = await this.request(this.SERVER + '/auth/resettokencheck', JSON.stringify({ token: token }), 'POST', 'json', false);
|
|
554
|
+
if (resp.errors || !resp.data)
|
|
555
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
556
|
+
return resp.data;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Восстановление пароля
|
|
560
|
+
* Сохранение нового пароля
|
|
561
|
+
* @param {*} token
|
|
562
|
+
* @param {*} password
|
|
563
|
+
* @returns
|
|
564
|
+
*/
|
|
565
|
+
async ResetPasswordSaveNewPassword(token, password) {
|
|
566
|
+
const resp = await this.request(this.SERVER + '/auth/resetconfirm', JSON.stringify({ token: token, password: password }), 'POST', 'json', false);
|
|
567
|
+
if (resp.errors || !resp.data)
|
|
568
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
569
|
+
return resp.data;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Подготовить объект под загрузку: загрузить данные из элементов Input [type=file] / Clipboard / DataTransfer [Drag&Drop/Clipboard]
|
|
573
|
+
* @param {object} values
|
|
574
|
+
*/
|
|
575
|
+
async prepare(values, asFormData = false) {
|
|
576
|
+
// В SSR этот метод не будет обрабатывать файлы, т.к. DOM и File API недоступны.
|
|
577
|
+
if (typeof window === 'undefined') {
|
|
578
|
+
return values;
|
|
579
|
+
}
|
|
580
|
+
// Если в один из параметров передан FileList или input[type=file], т.е. нужно загрузить файлы
|
|
581
|
+
const formData = new FormData();
|
|
582
|
+
for (let val in values) {
|
|
583
|
+
// Пустые значения нам не нужны
|
|
584
|
+
if (values[val] === undefined || values[val] === null)
|
|
585
|
+
continue;
|
|
586
|
+
// Любой вариант захода делаем массивом
|
|
587
|
+
let valuesArr = Array.isArray(values[val]) ? values[val] : [values[val]];
|
|
588
|
+
// Идём по каждому значению в массиве
|
|
589
|
+
for (let valueKey in valuesArr) {
|
|
590
|
+
let value = valuesArr[valueKey];
|
|
591
|
+
// Преобразуем
|
|
592
|
+
let isRef = false;
|
|
593
|
+
if (value instanceof Object
|
|
594
|
+
&& value.hasOwnProperty('_value')
|
|
595
|
+
&& (value._value instanceof Event ||
|
|
596
|
+
value._value instanceof HTMLInputElement ||
|
|
597
|
+
value._value instanceof ClipboardEvent ||
|
|
598
|
+
value._value instanceof DataTransfer ||
|
|
599
|
+
value._value instanceof FileList)) {
|
|
600
|
+
value = value.value;
|
|
601
|
+
isRef = true;
|
|
602
|
+
}
|
|
603
|
+
if (value instanceof Event && value.target instanceof HTMLInputElement && value.target.type === 'file')
|
|
604
|
+
value = value.target.files;
|
|
605
|
+
if (value instanceof HTMLInputElement && value.type === 'file')
|
|
606
|
+
value = value.files;
|
|
607
|
+
if (value instanceof ClipboardEvent)
|
|
608
|
+
value = value.clipboardData?.files;
|
|
609
|
+
if (value instanceof DataTransfer)
|
|
610
|
+
value = value.files;
|
|
611
|
+
if (value instanceof FileList) {
|
|
612
|
+
const newValues = [];
|
|
613
|
+
const files = Array.from(value);
|
|
614
|
+
for (let index = 0; index < files.length; index++) {
|
|
615
|
+
formData.append(val + "[]", files[index]);
|
|
616
|
+
newValues.push({
|
|
617
|
+
'name': files[index].name,
|
|
618
|
+
'data': asFormData ? null : await this.readFileAsync(files[index]),
|
|
619
|
+
'number': index,
|
|
620
|
+
'id': this.generateID(32)
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (Array.isArray(values[val])) {
|
|
624
|
+
values[val].splice(parseInt(valueKey), 1, ...newValues);
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
values[val] = newValues;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
formData.append("json", JSON.stringify(values));
|
|
633
|
+
return asFormData ? formData : values;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Прочесть файл асинхронно
|
|
637
|
+
* @param {File} file
|
|
638
|
+
* @param {'data' | 'text'} readAs
|
|
639
|
+
* @returns {Promise<string>}
|
|
640
|
+
*/
|
|
641
|
+
readFileAsync(file, readAs = 'data') {
|
|
642
|
+
return new Promise((resolve, reject) => {
|
|
643
|
+
let reader = new FileReader();
|
|
644
|
+
reader.onloadend = () => { resolve(reader.result); };
|
|
645
|
+
reader.onerror = reject;
|
|
646
|
+
if (readAs === 'data')
|
|
647
|
+
reader.readAsDataURL(file);
|
|
648
|
+
if (readAs === 'text')
|
|
649
|
+
reader.readAsText(file);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Заполнить существующий объект пришедшими из БД данными
|
|
654
|
+
* сохраняя при этом оригинальные классы и функции
|
|
655
|
+
* @param {*} object
|
|
656
|
+
* @param {*} values
|
|
657
|
+
*/
|
|
658
|
+
fillObject(object, values) {
|
|
659
|
+
for (let prop in object) {
|
|
660
|
+
if (!values.hasOwnProperty(prop))
|
|
661
|
+
continue;
|
|
662
|
+
if (typeof values[prop] === 'object' && typeof object[prop] === 'object' && values[prop] !== null && object[prop] !== null) {
|
|
663
|
+
this.fillObject(object[prop], values[prop]);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
object[prop] = values[prop];
|
|
667
|
+
}
|
|
668
|
+
return object;
|
|
669
|
+
}
|
|
670
|
+
generateID(length) {
|
|
671
|
+
let result = '';
|
|
672
|
+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
673
|
+
const charactersLength = characters.length;
|
|
674
|
+
let counter = 0;
|
|
675
|
+
while (counter < length) {
|
|
676
|
+
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
|
677
|
+
counter += 1;
|
|
678
|
+
}
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Вычисляет на лету детерминированную строку, которая будет использоваться как часть ключа для подписи.
|
|
683
|
+
* Ключ генерируется на основе последовательности числовых преобразований, что делает его неочевидным
|
|
684
|
+
* при статическом анализе кода. Этот метод всегда возвращает один и тот же результат.
|
|
685
|
+
* @returns {string} Детерминированно сгенерированная строка.
|
|
686
|
+
*/
|
|
687
|
+
getDeterministicKeyPart() {
|
|
688
|
+
// Начальная последовательность чисел. Она может быть любой, главное - постоянной.
|
|
689
|
+
const sequence = [5, 12, 23, 8, 17, 3, 29, 14, 31, 2, 11, 28, 7, 20, 1, 16, 22, 5, 13, 19, 4, 25, 10, 30, 9, 21, 6, 18, 27, 15, 24, 26];
|
|
690
|
+
// "Магическое число" для усложнения преобразований.
|
|
691
|
+
const magicNumber = 177;
|
|
692
|
+
let result = '';
|
|
693
|
+
for (let i = 0; i < sequence.length; i++) {
|
|
694
|
+
const num = sequence[i];
|
|
695
|
+
let charCode;
|
|
696
|
+
// В зависимости от позиции применяем разные преобразования,
|
|
697
|
+
// чтобы получить символы в разных регистрах и цифры.
|
|
698
|
+
switch (i % 3) {
|
|
699
|
+
case 0:
|
|
700
|
+
// Генерируем заглавную букву (A-Z)
|
|
701
|
+
charCode = (num * magicNumber + i) % 26;
|
|
702
|
+
result += String.fromCharCode(65 + charCode);
|
|
703
|
+
break;
|
|
704
|
+
case 1:
|
|
705
|
+
// Генерируем строчную букву (a-z)
|
|
706
|
+
charCode = (num + magicNumber * (i + 1)) % 26;
|
|
707
|
+
result += String.fromCharCode(97 + charCode);
|
|
708
|
+
break;
|
|
709
|
+
case 2:
|
|
710
|
+
// Генерируем цифру (0-9)
|
|
711
|
+
charCode = (num * (i + 1) + magicNumber) % 10;
|
|
712
|
+
result += String.fromCharCode(48 + charCode);
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return result;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Создаёт подпись для запроса на основе его параметров, используя лучшие практики.
|
|
720
|
+
* Собирает все GET и POST параметры, сортирует их и формирует каноническую строку для хеширования.
|
|
721
|
+
* @param url URL запроса, включая GET-параметры.
|
|
722
|
+
* @param params POST-параметры запроса (в виде объекта).
|
|
723
|
+
* @returns Promise<string> Строка подписи.
|
|
724
|
+
*/
|
|
725
|
+
async _generateRequestSignature(url, params) {
|
|
726
|
+
const secretKey = (this.token ?? '') + this.getDeterministicKeyPart();
|
|
727
|
+
// Если параметры - это FormData или не объект, мы не можем их надежно обработать для подписи.
|
|
728
|
+
// В этом случае подписываем пустую строку, как и раньше.
|
|
729
|
+
if (params instanceof FormData || typeof params !== 'object' || params === null) {
|
|
730
|
+
return this.hmac_sha256('', secretKey);
|
|
731
|
+
}
|
|
732
|
+
const allParams = {};
|
|
733
|
+
// 1. Извлекаем GET-параметры из URL
|
|
734
|
+
if (url.includes('?')) {
|
|
735
|
+
const queryString = url.split('?')[1];
|
|
736
|
+
const urlParams = new URLSearchParams(queryString);
|
|
737
|
+
urlParams.forEach((value, key) => {
|
|
738
|
+
allParams[key] = value;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// 2. Добавляем POST-параметры из тела запроса
|
|
742
|
+
Object.assign(allParams, params);
|
|
743
|
+
// 3. Создаём каноническую строку
|
|
744
|
+
// Сортируем ключи для консистентности
|
|
745
|
+
const sortedKeys = Object.keys(allParams).sort();
|
|
746
|
+
// Формируем строку вида "key1=value1&key2=value2..."
|
|
747
|
+
const canonicalString = sortedKeys
|
|
748
|
+
.map(key => {
|
|
749
|
+
const value = allParams[key];
|
|
750
|
+
// Пропускаем null и undefined значения, они не участвуют в подписи
|
|
751
|
+
if (value === null || value === undefined) {
|
|
752
|
+
return '';
|
|
753
|
+
}
|
|
754
|
+
// Если значение - объект (включая массив), заменяем его на слово "object" для консистентности подписи.
|
|
755
|
+
const stringValue = (typeof value === 'object' && value !== null) ? 'object' : String(value);
|
|
756
|
+
return `${encodeURIComponent(key)}=${encodeURIComponent(stringValue)}`;
|
|
757
|
+
})
|
|
758
|
+
.filter(part => part !== '') // Убираем пустые части
|
|
759
|
+
.join('&');
|
|
760
|
+
return this.hmac_sha256(canonicalString, secretKey);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
*
|
|
764
|
+
* @param {string} data
|
|
765
|
+
* @param {string} key
|
|
766
|
+
* @returns
|
|
767
|
+
*/
|
|
768
|
+
async hmac_sha256(message, secret_key) {
|
|
769
|
+
if (message === null || message === undefined)
|
|
770
|
+
message = "";
|
|
771
|
+
if (typeof message !== 'string')
|
|
772
|
+
message = JSON.stringify(message);
|
|
773
|
+
const enc = new TextEncoder();
|
|
774
|
+
const encodedKey = enc.encode(secret_key);
|
|
775
|
+
const encodedMessage = enc.encode(message);
|
|
776
|
+
const data = encodedMessage;
|
|
777
|
+
const key = encodedKey;
|
|
778
|
+
if (typeof window === 'undefined') {
|
|
779
|
+
// Динамически импортируем модуль 'crypto' для Node.js, чтобы избежать ошибок на фронтенде,
|
|
780
|
+
// где этот модуль недоступен.
|
|
781
|
+
// @ts-ignore
|
|
782
|
+
const { createHmac } = await import('crypto');
|
|
783
|
+
const hash = createHmac('sha256', key).update(data).digest('hex');
|
|
784
|
+
return Promise.resolve(hash);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
if (typeof window.crypto?.subtle === 'undefined')
|
|
788
|
+
return Promise.resolve("");
|
|
789
|
+
return window.crypto.subtle
|
|
790
|
+
.importKey('raw', key, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign', 'verify'])
|
|
791
|
+
.then(key => window.crypto.subtle.sign('HMAC', key, data))
|
|
792
|
+
.then(signature => {
|
|
793
|
+
const signatureArray = new Uint8Array(signature);
|
|
794
|
+
return Array.from(signatureArray)
|
|
795
|
+
.map(byte => byte.toString(16).padStart(2, '0'))
|
|
796
|
+
.join('');
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Экземпляр-синглтон класса FLAMEREST.
|
|
803
|
+
* Используйте его для всех запросов в приложении.
|
|
804
|
+
* `import REST from './REST.ts';`
|
|
805
|
+
*/
|
|
806
|
+
const REST = new FLAMEREST();
|
|
807
|
+
if (typeof window !== 'undefined') {
|
|
808
|
+
window.REST = REST;
|
|
809
|
+
}
|
|
810
|
+
export default REST;
|