flamerest 1.0.126 → 1.0.131

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