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