From 1c6e6ab24ace6b3bd9cb4f624014822eb055cd94 Mon Sep 17 00:00:00 2001 From: shats Date: Mon, 20 Apr 2026 01:17:00 +0700 Subject: [PATCH] Backend: SQLite storage, EvoBGP integration, filters in SQL Made-with: Cursor --- backend/data/router-lists.db | Bin 0 -> 69632 bytes backend/db/migrations/001_init.sql | 68 + backend/db/migrations/002_filter_rules.sql | 16 + backend/db/sqliteDb.js | 140 ++ backend/package-lock.json | 2075 ++++--------------- backend/package.json | 2 +- backend/routes/communitiesRoutes.js | 190 +- backend/routes/evobgpReferenceRoutes.js | 257 +++ backend/routes/filterRulesRoutes.js | 53 + backend/routes/filtersRoutes.js | 41 +- backend/routes/jsonDataRoutes.js | 76 +- backend/routes/mikrotikBackupRoutes.js | 37 +- backend/routes/mikrotikConfigRoutes.js | 10 +- backend/routes/miscRoutes.js | 299 ++- backend/routes/routeOptimizerRoutes.js | 6 +- backend/routes/serverConfigsRoutes.js | 27 +- backend/routes/serversRoutes.js | 51 +- backend/routes/textDataRoutes.js | 103 +- backend/server.js | 195 +- backend/services/backupFsService.js | 103 + backend/services/blobStorage.js | 167 ++ backend/services/evobgpClient.js | 426 ++++ backend/services/filterRulesStorage.js | 124 ++ backend/services/mikrotikBackupScheduler.js | 4 +- backend/services/s3Service.js | 363 ++-- backend/utils/helpers.js | 11 +- backend/utils/s3Helpers.js | 104 +- 27 files changed, 2406 insertions(+), 2542 deletions(-) create mode 100644 backend/data/router-lists.db create mode 100644 backend/db/migrations/001_init.sql create mode 100644 backend/db/migrations/002_filter_rules.sql create mode 100644 backend/db/sqliteDb.js create mode 100644 backend/routes/evobgpReferenceRoutes.js create mode 100644 backend/routes/filterRulesRoutes.js create mode 100644 backend/services/backupFsService.js create mode 100644 backend/services/blobStorage.js create mode 100644 backend/services/evobgpClient.js create mode 100644 backend/services/filterRulesStorage.js diff --git a/backend/data/router-lists.db b/backend/data/router-lists.db new file mode 100644 index 0000000000000000000000000000000000000000..c4dba9f4c2fe34745c13a1f7ecb007b0d94391a4 GIT binary patch literal 69632 zcmeI)PjA~~9Kdlq>5`^hnodG8940-Ns*P3_6(l5t(8yA6lx69brec+btTcJLc{G0( zyQSqYsRME0Ex?txf`s723mE5}IB?j31CQ-E&66}t95^ibT5XcYe*E}(KEI!z#Ill| z`(@Xc;-J%O)qU~4v1}v~#wS7;hLKSJSJZ#JET|WAaX~$s{r{m=V=oiN^=Et8+$&=_ z^^1{vnEQF@v)nt`SC<#Fzb$^h_+IAsOlkgyOe+1sd|g#wK>z^+5J2ER3p`m)TZL8g zY|(8u<%!!oaT@i;W9i6~uG^DdztHx3uJo>ys&=tvi>=D0{XmS>%`OPBT@l(YVy)kh zQ}^@F*Ot=O>ZtnP_B z_TG9>qg`)FuUl`(L5<3GO;mQvWwB}BD(;qRVl`}KHLSkhX@+eDwZ+Zy_DvC2k2atB z((&A{!`8McHT$+*)oVz<{wUlzcnEqF?swE})_vJjgI8-W-Y;_2YGfDkALdfl-Br^F zqvAb1RG&7S`jOuW%W;UJs5l#%-SXqhX)B*s5e<=#WzTavZPf>*<>{rY#;k7>Yk^{C z|A}n)!LDPwn75A%gRcYI%QayJ~OUt^{Y*uwGtN?OS%$u9WN@ z5eRrgwE18X*tE;Gnkh=folAnWtK?J*V4~2kuGO#KMK4jjId=)YH21 zY(8z3Ec2`|((+&eaQY`8+BhzE`?6Rm;Sq+uTH7f z1@q}^XVeMVr_HG&9T}fqCp4lZ`FI_bn)!6f`q-K@jfJsvHo~X*dzVtywQJ^H80!AC z?8&D`vfYp)#grEQk&R*O*43W*vvN|s0T;~Yx1%=IdvB}mwA@F%kk<1?SIp_{j&2^G zg5tNGf1iQ+({7NEV{cSqx3YDA*AB_Uu=n55;+Es&P?p8Hl(k!!WH~k%XJc&DCD%+_ zSFf69DxV~DPi58HLLz0Au1@N0+_~%TRwT2q1s}0tg_000IagfB*srATUJ&3+9}Wxn%zM)30w=g8Tnw z?r%f=u^@l|0tg_000IagfB*srAb`LW3M7-6OmzSMrIC9%g#%RW2q1s}0tg_000IagfB*srEGJjY;miL-F1eB! zzW(R?|0D?l2q1s}0tg_000IagfB*v1FTnTzr$5Gw5CH@bKmY**5I_I{1Q0*~0j~ea Y0R#|0009ILKmY**5I_I{1g2l$AHAarmH+?% literal 0 HcmV?d00001 diff --git a/backend/db/migrations/001_init.sql b/backend/db/migrations/001_init.sql new file mode 100644 index 0000000..823c059 --- /dev/null +++ b/backend/db/migrations/001_init.sql @@ -0,0 +1,68 @@ +-- Router-lists local storage (SQLite). EvoBGP holds reference lists; this DB holds config + caches. + +CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER NOT NULL UNIQUE, + applied_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS blobs ( + object_key TEXT PRIMARY KEY, + namespace TEXT NOT NULL CHECK (namespace IN ( + 'config', + 'derived', + 'per_server', + 'cache' + )), + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + body BLOB NOT NULL, + byte_size INTEGER NOT NULL, + etag TEXT NOT NULL, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_blobs_namespace_updated + ON blobs (namespace, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_blobs_prefix + ON blobs (object_key); + +CREATE TABLE IF NOT EXISTS blob_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + object_key TEXT NOT NULL, + body BLOB NOT NULL, + byte_size INTEGER NOT NULL, + etag TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (object_key) REFERENCES blobs(object_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_blob_versions_key_time + ON blob_versions (object_key, created_at DESC); + +CREATE TABLE IF NOT EXISTS versioned_keys ( + object_key TEXT PRIMARY KEY +); + +INSERT OR IGNORE INTO versioned_keys (object_key) VALUES + ('filters.json'), + ('servers.json'); + +CREATE TABLE IF NOT EXISTS cache_entries ( + cache_key TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'cache', + body BLOB NOT NULL, + byte_size INTEGER NOT NULL, + etag TEXT, + updated_at INTEGER NOT NULL, + expires_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_cache_expires + ON cache_entries (expires_at); diff --git a/backend/db/migrations/002_filter_rules.sql b/backend/db/migrations/002_filter_rules.sql new file mode 100644 index 0000000..b82b414 --- /dev/null +++ b/backend/db/migrations/002_filter_rules.sql @@ -0,0 +1,16 @@ +-- Нормализованные глобальные и simple-фильтры (вместо filters.json / simple-filters.json в blobs) + +CREATE TABLE IF NOT EXISTS app_filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL CHECK (scope IN ('global', 'simple')), + position INTEGER NOT NULL, + community TEXT NOT NULL, + gateway TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + extra_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_app_filter_rules_scope_pos ON app_filter_rules(scope, position); + +DELETE FROM versioned_keys WHERE object_key = 'filters.json'; diff --git a/backend/db/sqliteDb.js b/backend/db/sqliteDb.js new file mode 100644 index 0000000..81d4ef8 --- /dev/null +++ b/backend/db/sqliteDb.js @@ -0,0 +1,140 @@ +/** + * Single-process SQLite connection, migrations, PRAGMAs. + * Designed so the same API can later move to a dedicated storage service process. + */ + +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +let _db = null; +let _cachePurgeTimer = null; + +function defaultDbPath() { + return path.join(__dirname, '..', 'data', 'router-lists.db'); +} + +function getDbPath() { + const p = process.env.SQLITE_PATH || defaultDbPath(); + const dir = path.dirname(p); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return p; +} + +function applyMigration001(db) { + const sql = fs.readFileSync(path.join(__dirname, 'migrations', '001_init.sql'), 'utf8'); + db.exec(sql); +} + +function applyMigration002(db) { + const sql = fs.readFileSync(path.join(__dirname, 'migrations', '002_filter_rules.sql'), 'utf8'); + db.exec(sql); + + const migrateBlobToRules = (objectKey, scope) => { + const row = db.prepare('SELECT body FROM blobs WHERE object_key = ?').get(objectKey); + if (!row) return; + let bodyStr = ''; + try { + bodyStr = Buffer.isBuffer(row.body) ? row.body.toString('utf8') : String(row.body); + } catch { + return; + } + let arr = []; + try { + const parsed = JSON.parse(bodyStr); + arr = Array.isArray(parsed) ? parsed : []; + } catch { + return; + } + const now = Date.now(); + const delRules = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?'); + const ins = db.prepare( + `INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + const run = () => { + delRules.run(scope); + for (let i = 0; i < arr.length; i++) { + const it = arr[i] || {}; + const community = String(it.community || '').trim(); + const gateway = String(it.gateway || '').trim(); + const description = String(it.description || '').trim(); + const extra = { ...it }; + delete extra.community; + delete extra.gateway; + delete extra.description; + ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now); + } + db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run( + `filter_rules_mtime_${scope}`, + String(now), + ); + db.prepare('DELETE FROM blob_versions WHERE object_key = ?').run(objectKey); + db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey); + }; + db.transaction(run)(); + }; + + migrateBlobToRules('filters.json', 'global'); + migrateBlobToRules('filter-manager/simple-filters.json', 'simple'); +} + +/** + * @returns {import('better-sqlite3').Database} + */ +function openDatabase() { + if (_db) return _db; + + const filePath = getDbPath(); + const db = new Database(filePath); + + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.pragma('foreign_keys = ON'); + db.pragma(`busy_timeout = ${Number(process.env.SQLITE_BUSY_MS) || 8000}`); + const cacheKb = Number(process.env.SQLITE_CACHE_KB); + if (Number.isFinite(cacheKb) && cacheKb !== 0) { + db.pragma(`cache_size = ${-Math.abs(Math.floor(cacheKb))}`); + } + + let userVersion = db.pragma('user_version', { simple: true }); + if (userVersion < 1) { + applyMigration001(db); + db.pragma('user_version = 1'); + const now = Date.now(); + db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(now); + userVersion = 1; + } + if (userVersion < 2) { + applyMigration002(db); + db.pragma('user_version = 2'); + const now2 = Date.now(); + db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (2, ?)').run(now2); + } + + _db = db; + + if (!_cachePurgeTimer) { + const intervalMs = Math.max(60_000, Number(process.env.SQLITE_CACHE_PURGE_MS) || 300_000); + _cachePurgeTimer = setInterval(purgeExpiredCacheEntries, intervalMs); + _cachePurgeTimer.unref?.(); + } + + return db; +} + +function purgeExpiredCacheEntries() { + const db = openDatabase(); + const now = Date.now(); + try { + db.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now); + } catch (_) {} +} + +module.exports = { + openDatabase, + getDbPath, + purgeExpiredCacheEntries, +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index d497f79..616b4c3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@aws-sdk/client-s3": "^3.687.0", "ajv": "^8.17.1", + "better-sqlite3": "^11.6.0", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^17.0.1", @@ -25,877 +25,6 @@ "nodemon": "^3.1.10" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.864.0.tgz", - "integrity": "sha512-QGYi9bWliewxumsvbJLLyx9WC0a4DP4F+utygBcq0zwPxaM0xDfBspQvP1dsepi7mW5aAjZmJ2+Xb7X0EhzJ/g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/credential-provider-node": "3.864.0", - "@aws-sdk/middleware-bucket-endpoint": "3.862.0", - "@aws-sdk/middleware-expect-continue": "3.862.0", - "@aws-sdk/middleware-flexible-checksums": "3.864.0", - "@aws-sdk/middleware-host-header": "3.862.0", - "@aws-sdk/middleware-location-constraint": "3.862.0", - "@aws-sdk/middleware-logger": "3.862.0", - "@aws-sdk/middleware-recursion-detection": "3.862.0", - "@aws-sdk/middleware-sdk-s3": "3.864.0", - "@aws-sdk/middleware-ssec": "3.862.0", - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/region-config-resolver": "3.862.0", - "@aws-sdk/signature-v4-multi-region": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@aws-sdk/util-user-agent-browser": "3.862.0", - "@aws-sdk/util-user-agent-node": "3.864.0", - "@aws-sdk/xml-builder": "3.862.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/eventstream-serde-browser": "^4.0.5", - "@smithy/eventstream-serde-config-resolver": "^4.1.3", - "@smithy/eventstream-serde-node": "^4.0.5", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-blob-browser": "^4.0.5", - "@smithy/hash-node": "^4.0.5", - "@smithy/hash-stream-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/md5-js": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.864.0.tgz", - "integrity": "sha512-THiOp0OpQROEKZ6IdDCDNNh3qnNn/kFFaTSOiugDpgcE5QdsOxh1/RXq7LmHpTJum3cmnFf8jG59PHcz9Tjnlw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/middleware-host-header": "3.862.0", - "@aws-sdk/middleware-logger": "3.862.0", - "@aws-sdk/middleware-recursion-detection": "3.862.0", - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/region-config-resolver": "3.862.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@aws-sdk/util-user-agent-browser": "3.862.0", - "@aws-sdk/util-user-agent-node": "3.864.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.864.0.tgz", - "integrity": "sha512-LFUREbobleHEln+Zf7IG83lAZwvHZG0stI7UU0CtwyuhQy5Yx0rKksHNOCmlM7MpTEbSCfntEhYi3jUaY5e5lg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.862.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.864.0.tgz", - "integrity": "sha512-StJPOI2Rt8UE6lYjXUpg6tqSZaM72xg46ljPg8kIevtBAAfdtq9K20qT/kSliWGIBocMFAv0g2mC0hAa+ECyvg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.864.0.tgz", - "integrity": "sha512-E/RFVxGTuGnuD+9pFPH2j4l6HvrXzPhmpL8H8nOoJUosjx7d4v93GJMbbl1v/fkDLqW9qN4Jx2cI6PAjohA6OA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.864.0.tgz", - "integrity": "sha512-PlxrijguR1gxyPd5EYam6OfWLarj2MJGf07DvCx9MAuQkw77HBnsu6+XbV8fQriFuoJVTBLn9ROhMr/ROAYfUg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/credential-provider-env": "3.864.0", - "@aws-sdk/credential-provider-http": "3.864.0", - "@aws-sdk/credential-provider-process": "3.864.0", - "@aws-sdk/credential-provider-sso": "3.864.0", - "@aws-sdk/credential-provider-web-identity": "3.864.0", - "@aws-sdk/nested-clients": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.864.0.tgz", - "integrity": "sha512-2BEymFeXURS+4jE9tP3vahPwbYRl0/1MVaFZcijj6pq+nf5EPGvkFillbdBRdc98ZI2NedZgSKu3gfZXgYdUhQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.864.0", - "@aws-sdk/credential-provider-http": "3.864.0", - "@aws-sdk/credential-provider-ini": "3.864.0", - "@aws-sdk/credential-provider-process": "3.864.0", - "@aws-sdk/credential-provider-sso": "3.864.0", - "@aws-sdk/credential-provider-web-identity": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.864.0.tgz", - "integrity": "sha512-Zxnn1hxhq7EOqXhVYgkF4rI9MnaO3+6bSg/tErnBQ3F8kDpA7CFU24G1YxwaJXp2X4aX3LwthefmSJHwcVP/2g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.864.0.tgz", - "integrity": "sha512-UPyPNQbxDwHVGmgWdGg9/9yvzuedRQVF5jtMkmP565YX9pKZ8wYAcXhcYdNPWFvH0GYdB0crKOmvib+bmCuwkw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.864.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/token-providers": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.864.0.tgz", - "integrity": "sha512-nNcjPN4SYg8drLwqK0vgVeSvxeGQiD0FxOaT38mV2H8cu0C5NzpvA+14Xy+W6vT84dxgmJYKk71Cr5QL2Oz+rA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/nested-clients": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.862.0.tgz", - "integrity": "sha512-Wcsc7VPLjImQw+CP1/YkwyofMs9Ab6dVq96iS8p0zv0C6YTaMjvillkau4zFfrrrTshdzFWKptIFhKK8Zsei1g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.804.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.862.0.tgz", - "integrity": "sha512-oG3AaVUJ+26p0ESU4INFn6MmqqiBFZGrebST66Or+YBhteed2rbbFl7mCfjtPWUFgquQlvT1UP19P3LjQKeKpw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.864.0.tgz", - "integrity": "sha512-MvakvzPZi9uyP3YADuIqtk/FAcPFkyYFWVVMf5iFs/rCdk0CUzn02Qf4CSuyhbkS6Y0KrAsMgKR4MgklPU79Wg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.862.0.tgz", - "integrity": "sha512-jDje8dCFeFHfuCAxMDXBs8hy8q9NCTlyK4ThyyfAj3U4Pixly2mmzY2u7b7AyGhWsjJNx8uhTjlYq5zkQPQCYw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.862.0.tgz", - "integrity": "sha512-MnwLxCw7Cc9OngEH3SHFhrLlDI9WVxaBkp3oTsdY9JE7v8OE38wQ9vtjaRsynjwu0WRtrctSHbpd7h/QVvtjyA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.862.0.tgz", - "integrity": "sha512-N/bXSJznNBR/i7Ofmf9+gM6dx/SPBK09ZWLKsW5iQjqKxAKn/2DozlnE54uiEs1saHZWoNDRg69Ww4XYYSlG1Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.862.0.tgz", - "integrity": "sha512-KVoo3IOzEkTq97YKM4uxZcYFSNnMkhW/qj22csofLegZi5fk90ztUnnaeKfaEJHfHp/tm1Y3uSoOXH45s++kKQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.864.0.tgz", - "integrity": "sha512-GjYPZ6Xnqo17NnC8NIQyvvdzzO7dm+Ks7gpxD/HsbXPmV2aEfuFveJXneGW9e1BheSKFff6FPDWu8Gaj2Iu1yg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.804.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.862.0.tgz", - "integrity": "sha512-72VtP7DZC8lYTE2L3Efx2BrD98oe9WTK8X6hmd3WTLkbIjvgWQWIdjgaFXBs8WevsXkewIctfyA3KEezvL5ggw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.864.0.tgz", - "integrity": "sha512-wrddonw4EyLNSNBrApzEhpSrDwJiNfjxDm5E+bn8n32BbAojXASH8W8jNpxz/jMgNkkJNxCfyqybGKzBX0OhbQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@smithy/core": "^3.8.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.864.0.tgz", - "integrity": "sha512-H1C+NjSmz2y8Tbgh7Yy89J20yD/hVyk15hNoZDbCYkXg0M358KS7KVIEYs8E2aPOCr1sK3HBE819D/yvdMgokA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/middleware-host-header": "3.862.0", - "@aws-sdk/middleware-logger": "3.862.0", - "@aws-sdk/middleware-recursion-detection": "3.862.0", - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/region-config-resolver": "3.862.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@aws-sdk/util-user-agent-browser": "3.862.0", - "@aws-sdk/util-user-agent-node": "3.864.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.862.0.tgz", - "integrity": "sha512-VisR+/HuVFICrBPY+q9novEiE4b3mvDofWqyvmxHcWM7HumTz9ZQSuEtnlB/92GVM3KDUrR9EmBHNRrfXYZkcQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.864.0.tgz", - "integrity": "sha512-w2HIn/WIcUyv1bmyCpRUKHXB5KdFGzyxPkp/YK5g+/FuGdnFFYWGfcO8O+How4jwrZTarBYsAHW9ggoKvwr37w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.864.0.tgz", - "integrity": "sha512-gTc2QHOBo05SCwVA65dUtnJC6QERvFaPiuppGDSxoF7O5AQNK0UR/kMSenwLqN8b5E1oLYvQTv3C1idJLRX0cg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/nested-clients": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.862.0.tgz", - "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.804.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.804.0.tgz", - "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.862.0.tgz", - "integrity": "sha512-eCZuScdE9MWWkHGM2BJxm726MCmWk/dlHjOKvkM0sN1zxBellBMw5JohNss1Z8/TUmnW2gb9XHTOiHuGjOdksA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-endpoints": "^3.0.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.804.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.804.0.tgz", - "integrity": "sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.862.0.tgz", - "integrity": "sha512-BmPTlm0r9/10MMr5ND9E92r8KMZbq5ltYXYpVcUbAsnB1RJ8ASJuRoLne5F7mB3YMx0FJoOTuSq7LdQM3LgW3Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.864.0.tgz", - "integrity": "sha512-d+FjUm2eJEpP+FRpVR3z6KzMdx1qwxEYDz8jzNKwxYLBBquaBaP/wfoMtMQKAcbrR7aT9FZVZF7zDgzNxUvQlQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.862.0.tgz", - "integrity": "sha512-6Ed0kmC1NMbuFTEgNmamAUU1h5gShgxL1hBVLbEzUa3trX5aJBz1vU4bXaBTvOYUAnOHtiy1Ml4AMStd6hJnFA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -905,760 +34,6 @@ "node": ">=8.0.0" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", - "integrity": "sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", - "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", - "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", - "integrity": "sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.8.0.tgz", - "integrity": "sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.0.9", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.7.tgz", - "integrity": "sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.5.tgz", - "integrity": "sha512-miEUN+nz2UTNoRYRhRqVTJCx7jMeILdAurStT2XoS+mhokkmz1xAPp95DFW9Gxt4iF2VBqpeF9HbTQ3kY1viOA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.5.tgz", - "integrity": "sha512-LCUQUVTbM6HFKzImYlSB9w4xafZmpdmZsOh9rIl7riPC3osCgGFVP+wwvYVw6pXda9PPT9TcEZxaq3XE81EdJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.3.tgz", - "integrity": "sha512-yTTzw2jZjn/MbHu1pURbHdpjGbCuMHWncNBpJnQAPxOVnFUAbSIUSwafiphVDjNV93TdBJWmeVAds7yl5QCkcA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.5.tgz", - "integrity": "sha512-lGS10urI4CNzz6YlTe5EYG0YOpsSp3ra8MXyco4aqSkQDuyZPIw2hcaxDU82OUVtK7UY9hrSvgWtpsW5D4rb4g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.5.tgz", - "integrity": "sha512-JFnmu4SU36YYw3DIBVao3FsJh4Uw65vVDIqlWT4LzR6gXA0F3KP0IXFKKJrhaVzCBhAuMsrUUaT5I+/4ZhF7aw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", - "integrity": "sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.5.tgz", - "integrity": "sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.0.0", - "@smithy/chunked-blob-reader-native": "^4.0.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", - "integrity": "sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.5.tgz", - "integrity": "sha512-IJuDS3+VfWB67UC0GU0uYBG/TA30w+PlOaSo0GPm9UHS88A6rCP6uZxNjNYiyRtOcjv7TXn/60cW8ox1yuZsLg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", - "integrity": "sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", - "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/md5-js": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.5.tgz", - "integrity": "sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", - "integrity": "sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.18.tgz", - "integrity": "sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.1.19", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.19.tgz", - "integrity": "sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/service-error-classification": "^4.0.7", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.9.tgz", - "integrity": "sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.5.tgz", - "integrity": "sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.4.tgz", - "integrity": "sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.1.tgz", - "integrity": "sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.5.tgz", - "integrity": "sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.3.tgz", - "integrity": "sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.5.tgz", - "integrity": "sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-uri-escape": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.5.tgz", - "integrity": "sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.7.tgz", - "integrity": "sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.5.tgz", - "integrity": "sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.3.tgz", - "integrity": "sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-uri-escape": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.10.tgz", - "integrity": "sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.2.tgz", - "integrity": "sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.5.tgz", - "integrity": "sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", - "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", - "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", - "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", - "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", - "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.26.tgz", - "integrity": "sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.26.tgz", - "integrity": "sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.1.5", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.7.tgz", - "integrity": "sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", - "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.5.tgz", - "integrity": "sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.7.tgz", - "integrity": "sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.0.7", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.4.tgz", - "integrity": "sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", - "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", - "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.7.tgz", - "integrity": "sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "license": "MIT" - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1724,6 +99,37 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.6.0.tgz", + "integrity": "sha512-2J6k/eVxcFYY2SsTxsXrj6XylzHWPxveCn4fKPKZFv/Vqn/Cd7lOuX4d7rGQXT5zL+97MkNL3nSbCrIoe3LkgA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1737,12 +143,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -1782,12 +208,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/bowser": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.0.tgz", - "integrity": "sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1812,6 +232,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1875,6 +319,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -2003,6 +453,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2022,6 +496,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz", @@ -2063,6 +546,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2108,6 +600,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -2212,23 +713,11 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", @@ -2294,6 +783,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2364,6 +859,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -2460,6 +961,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -2473,6 +994,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2603,6 +1130,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2616,12 +1155,33 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2631,6 +1191,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -2721,6 +1293,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2798,6 +1379,33 @@ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -2847,6 +1455,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -2892,6 +1510,35 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2962,7 +1609,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3112,6 +1758,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -3152,17 +1843,23 @@ "node": ">= 0.8" } }, - "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/supports-color": { "version": "5.5.0", @@ -3177,6 +1874,34 @@ "node": ">=4" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -3227,11 +1952,17 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } }, "node_modules/type-is": { "version": "1.6.18", @@ -3262,6 +1993,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3279,6 +2016,12 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/backend/package.json b/backend/package.json index 1216f2c..12b13d4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,8 +16,8 @@ "license": "ISC", "description": "", "dependencies": { - "@aws-sdk/client-s3": "^3.687.0", "ajv": "^8.17.1", + "better-sqlite3": "^11.6.0", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^17.0.1", diff --git a/backend/routes/communitiesRoutes.js b/backend/routes/communitiesRoutes.js index 5d52560..3253c7f 100644 --- a/backend/routes/communitiesRoutes.js +++ b/backend/routes/communitiesRoutes.js @@ -1,38 +1,52 @@ /** - * Роуты для работы с communities (справочник BGP Community) + * Роуты для работы с communities (справочник BGP Community) — данные из EvoBGP API */ -const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service'); +const evobgpClient = require('../services/evobgpClient'); +const filterRulesStorage = require('../services/filterRulesStorage'); const { sendError, sendOk } = require('../middleware/errorHandler'); -const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); -const { streamToString } = require('../services/s3Service'); const validators = require('../lib/validators'); -const { getS3JsonWithHeaders } = require('../utils/s3Helpers'); -const S3_KEY = 'bgp_data/communities.json'; +function requireEvobgp(res) { + if (!evobgpClient.isConfigured()) { + return sendError( + res, + 503, + 'EvoBGP не настроен: задайте EVOBGP_API_URL и EVOBGP_API_TOKEN', + 'E_EVOBGP_NOT_CONFIGURED', + ); + } + return null; +} // GET /api/communities async function getCommunities(req, res) { - await getS3JsonWithHeaders(S3_KEY, req, res, { - transform: (communities) => { - // Нормализация - return communities - .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0) - .map((c) => ({ - value: String(c.value).trim(), - name: c.name ? String(c.name) : '', - description: c.description ? String(c.description) : '', - tags: Array.isArray(c.tags) ? c.tags.map(String) : [], - color: c.color ? String(c.color) : '', - icon: c.icon ? String(c.icon) : '' - })); - }, - defaultValue: [] - }); + const err = requireEvobgp(res); + if (err) return err; + try { + const communities = await evobgpClient.listCommunities(); + const normalized = communities + .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0) + .map((c) => ({ + value: String(c.value).trim(), + name: c.name ? String(c.name) : '', + description: c.description ? String(c.description) : '', + tags: Array.isArray(c.tags) ? c.tags.map(String) : [], + color: c.color ? String(c.color) : '', + icon: c.icon ? String(c.icon) : '', + })); + res.json(normalized); + } catch (e) { + console.error('getCommunities', e); + return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP'); + } } // POST /api/communities async function postCommunities(req, res) { + const err = requireEvobgp(res); + if (err) return err; + const { communities } = req.body; if (!Array.isArray(communities)) { @@ -42,26 +56,26 @@ async function postCommunities(req, res) { const seen = new Set(); const normalized = []; const validationErrors = []; - + for (let i = 0; i < communities.length; i++) { const entry = communities[i] || {}; const value = typeof entry.value === 'string' ? entry.value.trim() : ''; - + if (!value) { validationErrors.push(`Community at index ${i} is missing required field: value`); continue; } - + if (!validators.isValidCommunity(value)) { validationErrors.push(`Community at index ${i} has invalid value: ${value}`); continue; } - + if (seen.has(value)) { validationErrors.push(`Duplicate community value at index ${i}: ${value}`); continue; } - + seen.add(value); normalized.push({ value, @@ -75,97 +89,78 @@ async function postCommunities(req, res) { enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true, }); } - + if (validationErrors.length > 0) { return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors }); } try { - await s3.send(new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: S3_KEY, - Body: JSON.stringify(normalized, null, 2), - ContentType: 'application/json', - })); - invalidateCacheForKey(S3_KEY); - const { headMeta } = require('../services/s3Service'); - const meta = await headMeta(S3_KEY); - return sendOk(res, meta); + await evobgpClient.saveCommunitiesData(normalized); + return sendOk(res, { + etag: null, + lastModified: new Date().toISOString(), + contentLength: null, + }); } catch (error) { - console.error('Error writing communities to S3:', error); - return sendError(res, 500, 'Error writing communities to S3', 'E_S3'); + console.error('Error writing communities to EvoBGP:', error); + return sendError(res, 500, error.message || 'EvoBGP write failed', 'E_EVOBGP'); } } +function countCommunityInLines(rows, getCommunity) { + const usage = new Map(); + for (const row of rows || []) { + const c = getCommunity(row); + if (c) usage.set(c, (usage.get(c) || 0) + 1); + } + return usage; +} + // GET /api/communities/stats async function getCommunityStats(req, res) { + const err = requireEvobgp(res); + if (err) return err; try { - const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([ - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })), - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })), - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })), - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })), + const [domains, ipRanges, asns] = await Promise.allSettled([ + evobgpClient.fetchDomainsData(), + evobgpClient.fetchIpRangesData(), + evobgpClient.fetchAsnsData(), ]); - + const communityUsage = new Map(); - - // Подсчет использования в доменах - if (domainsRes.status === 'fulfilled') { - const text = await streamToString(domainsRes.value.Body); - text.split('\n').filter(Boolean).forEach(line => { - const parts = line.trim().split(/\s+/); - if (parts[1]) { - communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1); - } - }); + + if (domains.status === 'fulfilled') { + const m = countCommunityInLines(domains.value, (r) => r.community); + for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v); } - - // Подсчет в IP ranges - if (ipRangesRes.status === 'fulfilled') { - const text = await streamToString(ipRangesRes.value.Body); - text.split('\n').filter(Boolean).forEach(line => { - const parts = line.trim().split(/\s+/); - if (parts[1]) { - communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1); - } - }); + + if (ipRanges.status === 'fulfilled') { + const m = countCommunityInLines(ipRanges.value, (r) => r.community); + for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v); } - - // Подсчет в ASNs - if (asnsRes.status === 'fulfilled') { - const text = await streamToString(asnsRes.value.Body); - text.split('\n').filter(Boolean).forEach(line => { - const parts = line.trim().split(/\s+/); - if (parts[1]) { - communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1); - } - }); + + if (asns.status === 'fulfilled') { + const m = countCommunityInLines(asns.value, (r) => r.community); + for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v); } - - // Подсчет в фильтрах - if (filtersRes.status === 'fulfilled') { - try { - const text = await streamToString(filtersRes.value.Body); - const filters = JSON.parse(text); - if (Array.isArray(filters)) { - filters.forEach(f => { - if (f.community) { - communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1); - } - }); + + try { + const simpleFilters = filterRulesStorage.listRules('simple'); + for (const f of simpleFilters) { + if (f.community) { + communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1); } - } catch {} - } - - const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({ - community, - count, - })).sort((a, b) => b.count - a.count); - + } + } catch (_) {} + + const stats = Array.from(communityUsage.entries()) + .map(([community, count]) => ({ community, count })) + .sort((a, b) => b.count - a.count); + res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) }); } catch (error) { console.error('Error getting community stats:', error); - return sendError(res, 500, 'Error getting community stats', 'E_S3'); + return sendError(res, 500, 'Error getting community stats', 'E_EVOBGP'); } } @@ -174,4 +169,3 @@ module.exports = { postCommunities, getCommunityStats, }; - diff --git a/backend/routes/evobgpReferenceRoutes.js b/backend/routes/evobgpReferenceRoutes.js new file mode 100644 index 0000000..282fe88 --- /dev/null +++ b/backend/routes/evobgpReferenceRoutes.js @@ -0,0 +1,257 @@ +/** + * GET/POST для справочников из EvoBGP (ранее textDataRoutes + S3). + */ + +const crypto = require('crypto'); +const evobgpClient = require('../services/evobgpClient'); +const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); + +const countOnlyCache = { map: new Map(), ttlMs: 10_000 }; + +function getCountOnlyCache(cacheKey) { + const v = countOnlyCache.map.get(cacheKey); + if (!v) return null; + if (Date.now() > v.at + countOnlyCache.ttlMs) { + countOnlyCache.map.delete(cacheKey); + return null; + } + return v.value; +} + +function setCountOnlyCache(cacheKey, value) { + countOnlyCache.map.set(cacheKey, { value, at: Date.now() }); +} + +function etagForJson(obj) { + const s = JSON.stringify(obj); + const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); + return `"${h}"`; +} + +function filterPage(items, q, offset, limit) { + const qstr = q ? String(q).toLowerCase() : ''; + const filtered = !qstr + ? items + : items.filter((it) => JSON.stringify(it).toLowerCase().includes(qstr)); + const total = filtered.length; + if (limit > 0) { + return { items: filtered.slice(offset, offset + limit), total }; + } + return { items: filtered, total }; +} + +function createEvobgpListGET({ fetchItems, cachePrefix, validate }) { + return async (req, res) => { + const { q = '', offset, limit, countOnly, format } = req.query || {}; + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); + } + try { + if (countOnly === 'true') { + const cacheKey = `${cachePrefix}:count:${q}`; + const cached = getCountOnlyCache(cacheKey); + if (cached != null) { + return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); + } + const all = await fetchItems(); + const { total } = filterPage(all, q, 0, 0); + setCountOnlyCache(cacheKey, total); + return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); + } + + if (Number(limit) > 0) { + const all = await fetchItems(); + if (!validate(all)) { + return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); + } + const { items, total } = filterPage(all, q, Number(offset) || 0, Number(limit) || 0); + return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); + } + + const all = await fetchItems(); + if (!validate(all)) { + return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); + } + const etag = etagForJson(all); + res.set('ETag', etag); + res.set('Last-Modified', new Date().toUTCString()); + res.set('Content-Length-Source', String(Buffer.byteLength(JSON.stringify(all), 'utf8'))); + if (checkIfNoneMatch(req, res, etag)) return; + if (format === 'std') { + return res.json({ items: all, total: all.length, meta: {} }); + } + return res.json(all); + } catch (e) { + console.error(cachePrefix, e); + return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP'); + } + }; +} + +function readIfMatch(req) { + const raw = req.headers['if-match'] || req.headers['If-Match']; + if (!raw) return null; + return String(raw).trim(); +} + +function createEvobgpDomainsNewPOST({ validate, validateItem }) { + return async (req, res) => { + const { domains: items, etag: bodyEtag } = req.body || {}; + if (!Array.isArray(items)) { + return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); + } + if (!validate(items)) { + return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA'); + } + if (validateItem) { + const validationErrors = []; + for (let i = 0; i < items.length; i++) { + const errors = validateItem(items[i], i); + if (errors.length > 0) validationErrors.push(...errors); + } + if (validationErrors.length > 0) { + return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { + errors: validationErrors.slice(0, 10), + }); + } + } + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); + } + try { + const current = await evobgpClient.fetchDomainsData(); + const currentEtag = etagForJson(current); + const ifMatch = readIfMatch(req); + const expected = ifMatch || bodyEtag; + if (expected && String(expected).trim() !== currentEtag) { + return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH'); + } + const normalized = items.map((d) => ({ + domain: String(d.domain || '').trim().toLowerCase(), + community: String(d.community || '').trim(), + })); + await evobgpClient.saveDomainsData(normalized, current); + return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null }); + } catch (e) { + console.error(e); + return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP'); + } + }; +} + +function createEvobgpAsnsPOST({ validate, validateItem }) { + return async (req, res) => { + const { domains: items, etag: bodyEtag } = req.body || {}; + if (!Array.isArray(items)) { + return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); + } + if (!validate(items)) { + return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA'); + } + if (validateItem) { + const validationErrors = []; + for (let i = 0; i < items.length; i++) { + const errors = validateItem(items[i], i); + if (errors.length > 0) validationErrors.push(...errors); + } + if (validationErrors.length > 0) { + return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { + errors: validationErrors.slice(0, 10), + }); + } + } + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); + } + try { + const current = await evobgpClient.fetchAsnsData(); + const currentEtag = etagForJson( + current.map((a) => ({ domain: a.asn, type: a.community })), + ); + const ifMatch = readIfMatch(req); + const expected = ifMatch || bodyEtag; + if (expected && String(expected).trim() !== currentEtag) { + return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH'); + } + const normalized = items.map((a) => ({ + asn: String(a.domain || a.asn || '').trim(), + community: String(a.type || a.community || '').trim(), + })); + await evobgpClient.saveAsnsData(normalized, current); + return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null }); + } catch (e) { + console.error(e); + return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP'); + } + }; +} + +function createEvobgpIpRangesPOST({ validate, validateItem }) { + return async (req, res) => { + const payload = Array.isArray(req.body?.domains) + ? req.body.domains + : Array.isArray(req.body?.ipRanges) + ? req.body.ipRanges + : null; + const { etag: bodyEtag } = req.body || {}; + if (!Array.isArray(payload)) { + return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); + } + if (!validate(payload)) { + return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA'); + } + if (validateItem) { + const validationErrors = []; + for (let i = 0; i < payload.length; i++) { + const errors = validateItem(payload[i], i); + if (errors.length > 0) validationErrors.push(...errors); + } + if (validationErrors.length > 0) { + return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { + errors: validationErrors.slice(0, 10), + }); + } + } + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); + } + try { + const current = await evobgpClient.fetchIpRangesData(); + const currentEtag = etagForJson(current); + const ifMatch = readIfMatch(req); + const expected = ifMatch || bodyEtag; + if (expected && String(expected).trim() !== currentEtag) { + return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH'); + } + const normalized = payload.map((ip) => ({ + ipRange: String(ip.ipRange || '').trim(), + community: String(ip.community || '').trim(), + })); + await evobgpClient.saveIpRangesData(normalized, current); + return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null }); + } catch (e) { + console.error(e); + return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP'); + } + }; +} + +/** Legacy /api/domains: тот же модуль ASN, ответ в полях domain + type. */ +function createEvobgpLegacyDomainsGET({ validate }) { + return createEvobgpListGET({ + cachePrefix: 'domains', + validate, + fetchItems: async () => { + const rows = await evobgpClient.fetchAsnsData(); + return rows.map((r) => ({ domain: r.asn, type: r.community })); + }, + }); +} + +module.exports = { + createEvobgpListGET, + createEvobgpDomainsNewPOST, + createEvobgpAsnsPOST, + createEvobgpIpRangesPOST, + createEvobgpLegacyDomainsGET, +}; diff --git a/backend/routes/filterRulesRoutes.js b/backend/routes/filterRulesRoutes.js new file mode 100644 index 0000000..bb03005 --- /dev/null +++ b/backend/routes/filterRulesRoutes.js @@ -0,0 +1,53 @@ +/** + * GET/POST /api/filters и /api/simple-filters — данные в app_filter_rules, не в blobs. + */ + +const filterRulesStorage = require('../services/filterRulesStorage'); +const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); + +/** + * @param {'global'|'simple'} scope + * @param {(item: any, i: number) => string|null} validateItem + */ +function createFilterRulesRoutes(scope, validateItem) { + return { + get: async (req, res) => { + try { + const head = filterRulesStorage.headMetaForScope(scope); + if (head.etag) res.set('ETag', String(head.etag)); + if (head.lastModified) { + res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + } + if (typeof head.contentLength === 'number') { + res.set('Content-Length-Source', String(head.contentLength)); + } + if (checkIfNoneMatch(req, res, head.etag)) return; + res.json(filterRulesStorage.listRules(scope)); + } catch (e) { + console.error('filterRules get', e); + return sendError(res, 500, 'Error reading storage', 'E_STORAGE'); + } + }, + + post: async (req, res) => { + const { domains: items } = req.body; + if (!Array.isArray(items)) { + return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); + } + try { + const meta = filterRulesStorage.replaceRules(scope, items, { validateItem }); + return sendOk(res, meta); + } catch (e) { + if (e.code === 'E_SCHEMA') { + return sendError(res, 400, e.message || 'Validation failed', 'E_SCHEMA'); + } + console.error('filterRules post', e); + return sendError(res, 500, 'Error writing storage', 'E_STORAGE'); + } + }, + }; +} + +module.exports = { + createFilterRulesRoutes, +}; diff --git a/backend/routes/filtersRoutes.js b/backend/routes/filtersRoutes.js index cbb4913..a5d28fe 100644 --- a/backend/routes/filtersRoutes.js +++ b/backend/routes/filtersRoutes.js @@ -2,12 +2,10 @@ * Роуты для фильтров и генерации конфигураций MikroTik */ -const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service'); -const { sendError, sendOk } = require('../middleware/errorHandler'); -const { GetObjectCommand } = require('@aws-sdk/client-s3'); -const { streamToString } = require('../services/s3Service'); +const { writeS3TextObject } = require('../services/s3Service'); +const filterRulesStorage = require('../services/filterRulesStorage'); +const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler'); const { generateMikrotikConfig } = require('../utils/mikrotikConfig'); -const { getS3JsonWithHeaders } = require('../utils/s3Helpers'); // GET /api/filters/generate-config?format=text|json async function generateConfig(req, res) { @@ -16,13 +14,16 @@ async function generateConfig(req, res) { if (!['text', 'json'].includes(format)) { return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST'); } - await getS3JsonWithHeaders('filters.json', req, res, { - transform: (filters) => { - const result = generateMikrotikConfig(filters, { format }); - return format === 'json' ? result : { config: result }; - }, - defaultValue: [], - }); + const head = filterRulesStorage.headMetaForScope('global'); + if (head.etag) res.set('ETag', String(head.etag)); + if (head.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head.contentLength === 'number') { + res.set('Content-Length-Source', String(head.contentLength)); + } + if (checkIfNoneMatch(req, res, head.etag)) return; + const filters = filterRulesStorage.listRules('global'); + const result = generateMikrotikConfig(filters, { format }); + res.json(format === 'json' ? result : { config: result }); } catch (error) { console.error(error); return sendError(res, 500, 'Error generating configuration', 'E_S3'); @@ -32,17 +33,7 @@ async function generateConfig(req, res) { // POST /api/filters/export-config async function exportConfig(req, res) { try { - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })); - const fileContent = await streamToString(data.Body); - let filters = []; - - try { - filters = JSON.parse(fileContent); - if (!Array.isArray(filters)) filters = []; - } catch (parseError) { - console.error('Error parsing filters.json:', parseError); - filters = []; - } + const filters = filterRulesStorage.listRules('global'); if (filters.length === 0) { return res.json({ success: false, message: 'Нет фильтров для экспорта' }); @@ -50,10 +41,10 @@ async function exportConfig(req, res) { const config = generateMikrotikConfig(filters); await writeS3TextObject('mikrotik-frouting-config.txt', config); - res.json({ success: true, message: 'Конфигурация экспортирована в S3' }); + res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' }); } catch (error) { console.error(error); - return sendError(res, 500, 'Error exporting configuration', 'E_S3'); + return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE'); } } diff --git a/backend/routes/jsonDataRoutes.js b/backend/routes/jsonDataRoutes.js index de61278..afa61e4 100644 --- a/backend/routes/jsonDataRoutes.js +++ b/backend/routes/jsonDataRoutes.js @@ -2,14 +2,12 @@ * Общие роуты для JSON данных (servers, filters, billing и т.д.) */ -const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service'); +const { writeS3JsonObject, headMeta, readS3TextObject } = require('../services/s3Service'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); -const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { streamToString } = require('../services/s3Service'); /** * Создать GET эндпоинт для JSON данных - * @param {string} s3Key - Ключ в S3 + * @param {string} s3Key - Ключ объекта (логический, как раньше в S3) * @param {Object} options - Опции * @param {boolean} options.singleObject - Если true, возвращает объект вместо массива * @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта) @@ -17,66 +15,63 @@ const { streamToString } = require('../services/s3Service'); function createJsonDataGET(s3Key, options = {}) { const { singleObject = false, defaultValue } = options; const fallback = defaultValue !== undefined ? defaultValue : (singleObject ? {} : []); - + return async (req, res) => { try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); - const etag = head?.ETag || null; + const head = await headMeta(s3Key); + const etag = head?.etag || null; if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); if (checkIfNoneMatch(req, res, etag)) return; - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); - const fileContent = await streamToString(data.Body); + let fileContent; + try { + const data = await readS3TextObject(s3Key); + fileContent = data.body; + } catch (e) { + if (e?.code === 'NoSuchKey') { + return res.json(fallback); + } + throw e; + } + let items = fallback; - + try { const parsed = JSON.parse(fileContent); if (singleObject) { - // Для одиночного объекта - items = (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : fallback; + items = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : fallback; } else { - // Для массива items = Array.isArray(parsed) ? parsed : fallback; } } catch (parseError) { console.error(`Error parsing ${s3Key}:`, parseError); items = fallback; } - + res.json(items); } catch (error) { - if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json(fallback); - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } + console.error(error); + return sendError(res, 500, 'Error reading storage', 'E_STORAGE'); } }; } /** * Создать POST эндпоинт для JSON данных - * @param {string} s3Key - Ключ в S3 - * @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект) - * @param {Object} options - Опции - * @param {boolean} options.singleObject - Если true, ожидает объект вместо массива */ function createJsonDataPOST(s3Key, validateItem = null, options = {}) { const { singleObject = false } = options; - + return async (req, res) => { - const { domains: items } = req.body; // Используем 'domains' для обратной совместимости - + const { domains: items } = req.body; + if (singleObject) { - // Для одиночного объекта if (typeof items !== 'object' || items === null || Array.isArray(items)) { return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST'); } - - // Валидация объекта целиком + if (validateItem) { const error = validateItem(items); if (error) { @@ -84,12 +79,10 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) { } } } else { - // Для массива if (!Array.isArray(items)) { return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); } - - // Валидация каждого элемента массива + if (validateItem) { for (let i = 0; i < items.length; i++) { const error = validateItem(items[i], i); @@ -105,23 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) { return sendOk(res, meta); } catch (error) { console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3'); + return sendError(res, 500, 'Error writing storage', 'E_STORAGE'); } }; } -/** - * Создать роуты для JSON данных (GET + POST) - * @param {string} s3Key - Ключ в S3 - * @param {Function} validateItem - Функция валидации - * @param {Object} options - Опции - * @param {boolean} options.singleObject - Если true, работает с объектом вместо массива - * @param {any} options.defaultValue - Значение по умолчанию - */ function createJsonDataRoutes(s3Key, validateItem = null, options = {}) { return { get: createJsonDataGET(s3Key, options), - post: createJsonDataPOST(s3Key, validateItem, options) + post: createJsonDataPOST(s3Key, validateItem, options), }; } @@ -130,4 +115,3 @@ module.exports = { createJsonDataGET, createJsonDataPOST, }; - diff --git a/backend/routes/mikrotikBackupRoutes.js b/backend/routes/mikrotikBackupRoutes.js index e4bd390..ce1fad0 100644 --- a/backend/routes/mikrotikBackupRoutes.js +++ b/backend/routes/mikrotikBackupRoutes.js @@ -1,8 +1,8 @@ /** * Роуты для бэкапов конфигурации MikroTik - * Хранят сырый текст конфигурации (например, вывод /export) в S3. + * Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС. * - * Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc + * Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc * * Задачи: * - создать бэкап (ручной или из планировщика) @@ -13,7 +13,7 @@ const crypto = require('crypto'); const { sendError, sendOk } = require('../middleware/errorHandler'); -const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service'); +const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService'); const { readServersFromS3 } = require('./serversRoutes'); const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService'); const { encrypt, decrypt } = require('../utils/encryption'); @@ -61,7 +61,7 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) { let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`; - // Шифруем содержимое на S3, если задан ENCRYPTION_KEY + // Шифруем содержимое файла, если задан ENCRYPTION_KEY if (process.env.ENCRYPTION_KEY) { try { content = ENCRYPTED_PREFIX + encrypt(content); @@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) { } } - const meta = await writeS3TextObject(key, content, 'text/plain'); - return { key, createdAt: now.toISOString(), s3: meta }; + const meta = await writeBackupFile(key, content, 'text/plain'); + return { key, createdAt: now.toISOString(), storage: meta }; } /** * POST /api/mikrotik/backups * Body: { serverId: string, config: string, source?: string, comment?: string } * - * Сохраняет текст конфигурации (обычно вывод /export) в S3. + * Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске. * Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт. */ async function createBackup(req, res) { @@ -93,14 +93,14 @@ async function createBackup(req, res) { return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST'); } - const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment }); + const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment }); return res.json({ ok: true, key, serverId, createdAt, - s3, + storage, }); } catch (error) { console.error('createBackup error:', error); @@ -120,12 +120,11 @@ async function listBackups(req, res) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } - const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`; - const objects = await listS3Objects(prefix, { maxKeys: 200 }); + const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_'); + const objects = await listBackupObjects(safeId, 200); const items = objects - .filter(o => o.key && o.key.startsWith(prefix)) - .map(o => ({ + .map((o) => ({ key: o.key, serverId, createdAt: o.lastModified, @@ -156,7 +155,7 @@ async function getBackup(req, res) { return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST'); } - const data = await readS3TextObject(key); + const data = await readBackupFile(key); const config = decryptBackupContent(data.body || ''); return res.json({ @@ -191,8 +190,8 @@ async function diffBackups(req, res) { } const [a, b] = await Promise.all([ - readS3TextObject(keyA), - readS3TextObject(keyB), + readBackupFile(keyA), + readBackupFile(keyB), ]); const bodyA = decryptBackupContent(a.body || ''); @@ -265,7 +264,7 @@ async function getMikrotikClientForServerId(serverId) { * POST /api/mikrotik/backups/run * Body: { serverId: string } * - * Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3. + * Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск. */ async function runBackupNow(req, res) { try { @@ -293,7 +292,7 @@ async function runBackupNow(req, res) { return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT'); } - const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { + const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source: 'manual', comment: 'Manual backup from UI (/api/mikrotik/backups/run)', }); @@ -303,7 +302,7 @@ async function runBackupNow(req, res) { key, serverId, createdAt, - s3, + storage, }); } catch (error) { console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data); diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 67cc6a3..364265f 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -4,8 +4,7 @@ */ const crypto = require('crypto'); -const { GetObjectCommand } = require('@aws-sdk/client-s3'); -const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service'); +const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service'); const { sendError, sendOk } = require('../middleware/errorHandler'); const { decrypt } = require('../utils/encryption'); const { readServersFromS3 } = require('./serversRoutes'); @@ -54,12 +53,11 @@ function speedTestCacheKey(serverId, interfaceName) { /** Загрузить network-config из S3 */ async function loadNetworkConfig() { try { - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY })); - const body = await streamToString(data.Body); - const parsed = JSON.parse(body || '{}'); + const data = await readS3TextObject(NETWORK_CONFIG_KEY); + const parsed = JSON.parse(data.body || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (e) { - if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {}; + if (e?.code === 'NoSuchKey') return {}; throw e; } } diff --git a/backend/routes/miscRoutes.js b/backend/routes/miscRoutes.js index 2221da7..b0f717f 100644 --- a/backend/routes/miscRoutes.js +++ b/backend/routes/miscRoutes.js @@ -2,10 +2,18 @@ * Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.) */ -const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service'); +const { + writeS3TextObject, + writeS3JsonObject, + readS3TextObject, + headMeta, + listBlobVersionsForKey, + rollbackBlobVersion, +} = require('../services/s3Service'); +const evobgpClient = require('../services/evobgpClient'); +const filterRulesStorage = require('../services/filterRulesStorage'); const { sendError, sendOk } = require('../middleware/errorHandler'); const { resourceToKey, toIso } = require('../utils/helpers'); -const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); const validators = require('../lib/validators'); const net = require('net'); const http = require('http'); @@ -19,66 +27,97 @@ async function getS3LastModified(req, res) { { name: 'domainsNew', key: 'bgp_data/domains_community.txt' }, { name: 'asns', key: 'bgp_data/asns.txt' }, { name: 'servers', key: 'servers.json' }, - { name: 'filters', key: 'filters.json' }, + { name: 'filters', key: null }, { name: 'ipRanges', key: 'bgp_data/ips.txt' }, { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } ]; - const results = await Promise.allSettled( - keys.map(k => headMeta(k.key)) - ); - const out = {}; - results.forEach((r, idx) => { - const name = keys[idx].name; - if (r.status === 'fulfilled' && r.value) { - out[name] = r.value; - } else { - out[name] = null; + for (const k of keys) { + if (k.name === 'filters') { + try { + const head = filterRulesStorage.headMetaForScope('global'); + out.filters = { + etag: head.etag, + lastModified: head.lastModified, + contentLength: head.contentLength, + }; + } catch { + out.filters = null; + } + continue; } - }); - + if (['domainsNew', 'asns', 'ipRanges'].includes(k.name) && evobgpClient.isConfigured()) { + try { + await evobgpClient.fetchRouterListsCatalog(); + out[k.name] = { etag: null, lastModified: new Date().toISOString(), contentLength: null }; + } catch { + out[k.name] = null; + } + continue; + } + out[k.name] = await headMeta(k.key).catch(() => null); + } + res.json(out); } catch (error) { - console.error('Error fetching last modified dates from S3:', error); - return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3'); + console.error('Error fetching last modified dates:', error); + return sendError(res, 500, 'Error fetching last modified dates', 'E_STORAGE'); } } +const EVOBGP_ONLY_HISTORY_RESOURCES = new Set(['domains-new', 'asns', 'ip-ranges', 'filters']); + // GET /api/history/:resource async function getHistory(req, res) { const { resource } = req.params; const { countOnly, format } = req.query || {}; + if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) { + const note = + resource === 'filters' + ? 'Фильтры в SQL-таблице app_filter_rules; локальной истории отката через этот API нет' + : 'Версии хранятся в EvoBGP, локальной истории нет'; + if (countOnly === 'true') { + return res.json(format === 'std' ? { items: [], total: 0, meta: { noBlobHistory: true } } : { total: 0 }); + } + return res.json( + format === 'std' + ? { items: [], total: 0, meta: { noBlobHistory: true, note } } + : { items: [], total: 0 }, + ); + } + const key = resourceToKey(resource); - + if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); try { - const out = await s3.send(new ListObjectVersionsCommand({ - Bucket: BUCKET_NAME, - Prefix: key, - MaxKeys: 50 + const rows = listBlobVersionsForKey(key, 50); + const versionsAll = rows.map((r) => ({ + id: r.id, + object_key: r.object_key, + byte_size: r.byte_size, + etag: r.etag, + created_at: r.created_at, })); - - const versionsAll = (out.Versions || []).filter(v => v.Key === key); - + if (countOnly === 'true') { const total = versionsAll.length; return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } - - const versions = versionsAll.slice(0, 10).map(v => ({ - versionId: v.VersionId, - isLatest: v.IsLatest, - lastModified: toIso(v.LastModified), - size: v.Size, - etag: v.ETag + + const versions = versionsAll.slice(0, 10).map((v, idx) => ({ + versionId: String(v.id), + isLatest: idx === 0, + lastModified: toIso(v.created_at), + size: v.byte_size, + etag: v.etag, })); - + return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions }); } catch (e) { console.error('history error', e); - return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) }); + return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) }); } } @@ -86,125 +125,76 @@ async function getHistory(req, res) { async function postRollback(req, res) { const { resource } = req.params; const { versionId } = req.body || {}; + if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) { + const msg = + resource === 'filters' + ? 'Откат версий для фильтров не поддерживается (данные в SQL, не в blob_versions)' + : 'Откат версий для этого справочника не поддерживается (данные в EvoBGP)'; + return sendError(res, 400, msg, resource === 'filters' ? 'E_HISTORY_FILTERS_SQL' : 'E_HISTORY_EV'); + } + const key = resourceToKey(resource); - + if (!key || !versionId) { return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST'); } try { - await s3.send(new CopyObjectCommand({ - Bucket: BUCKET_NAME, - CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, - Key: key - })); - + rollbackBlobVersion(key, versionId); const meta = await headMeta(key); return sendOk(res, meta); } catch (e) { console.error('rollback error', e); - return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) }); + return sendError(res, 500, 'Error rollback', 'E_STORAGE', { error: String(e?.message || e) }); } } // GET /api/auto-urls async function getAutoUrls(req, res) { - const { checkIfNoneMatch } = require('../middleware/errorHandler'); - try { - const { HeadObjectCommand } = require('@aws-sdk/client-s3'); - const head = await s3.send(new HeadObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/auto_url/urls.txt' - })).catch(() => null); - - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/auto_url/urls.txt' - })); - const fileContent = await streamToString(data.Body); - const urls = fileContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { url: parts[0] || '', community: parts[1] || '' }; - }); - res.json(urls); - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); - } else { - console.error(error); - return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3'); + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); } + const rows = await evobgpClient.fetchAutoUrlsData(); + res.json(rows); + } catch (error) { + console.error(error); + return sendError(res, 500, 'Error reading auto URLs', 'E_EVOBGP'); } } // POST /api/auto-urls async function postAutoUrls(req, res) { const { urls } = req.body; - const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n'); - + if (!Array.isArray(urls)) { + return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST'); + } try { - const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent); - return sendOk(res, meta); + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); + } + await evobgpClient.saveAutoUrlsData(urls); + return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null }); } catch (error) { console.error(error); - return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3'); + return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP'); } } // POST /api/auto-urls/process async function processAutoUrls(req, res) { try { - // Загрузка URL списков - let urls = []; - try { - const urlsData = await s3.send(new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/auto_url/urls.txt' - })); - const urlsContent = await streamToString(urlsData.Body); - urls = urlsContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { url: parts[0] || '', community: parts[1] || '' }; - }); - } catch (error) { - if (error.code !== 'NoSuchKey') throw error; + if (!evobgpClient.isConfigured()) { + return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); } + const urls = await evobgpClient.fetchAutoUrlsData(); if (urls.length === 0) { return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST'); } - // Загрузка текущих IP и доменов - let currentIps = []; - try { - const ipsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })); - const ipsContent = await streamToString(ipsData.Body); - currentIps = ipsContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { ipRange: parts[0] || '', community: parts[1] || '' }; - }); - } catch (error) { - if (error.code !== 'NoSuchKey') throw error; - } - - let currentDomains = []; - try { - const dData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })); - const dContent = await streamToString(dData.Body); - currentDomains = dContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { domain: (parts[0] || '').toLowerCase(), community: parts[1] || '' }; - }); - } catch (error) { - if (error.code !== 'NoSuchKey') throw error; - } + const currentIps = await evobgpClient.fetchIpRangesData(); + const currentDomains = await evobgpClient.fetchDomainsData(); // Обработка URL const newIps = []; @@ -256,22 +246,8 @@ async function processAutoUrls(req, res) { const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain)); const allDomains = [...currentDomains, ...uniqueNewDomains]; - // Сохранение - const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n'); - await s3.send(new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/ips.txt', - Body: updatedIpsContent, - ContentType: 'text/plain' - })); - - const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n'); - await s3.send(new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/domains_community.txt', - Body: updatedDomainsContent, - ContentType: 'text/plain' - })); + await evobgpClient.saveIpRangesData(allIps, currentIps); + await evobgpClient.saveDomainsData(allDomains, currentDomains); const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`; return res.json({ @@ -285,7 +261,7 @@ async function processAutoUrls(req, res) { }); } catch (error) { console.error('Error processing auto URLs:', error); - return sendError(res, 500, 'Error processing auto URLs', 'E_S3'); + return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP'); } } @@ -357,9 +333,8 @@ function getPingServicesListFromSettings(uiSettings) { /** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */ async function loadUiSettingsSync() { try { - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })); - const jsonText = await streamToString(data.Body); - const parsed = JSON.parse(jsonText || '{}'); + const data = await readS3TextObject('bgp_data/rt_ui_settings.json'); + const parsed = JSON.parse(data.body || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; @@ -598,10 +573,10 @@ async function getServersAvailability(req, res) { return res.json({ ...availabilityCache.data, cached: true }); } - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' })); let servers = []; try { - servers = JSON.parse(await streamToString(data.Body)); + const data = await readS3TextObject('servers.json'); + servers = JSON.parse(data.body); if (!Array.isArray(servers)) servers = []; } catch { servers = []; @@ -683,12 +658,13 @@ async function updateBgpBackground(req, res) { // GET /api/ws/url async function getWsUrl(req, res) { try { - const settings = await s3.send(new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/rt_ui_settings.json' - })).then(async (d) => { - try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; } - }).catch(() => ({})); + let settings = {}; + try { + const d = await readS3TextObject('bgp_data/rt_ui_settings.json'); + settings = JSON.parse(d.body || '{}'); + } catch (_) { + settings = {}; + } const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || ''; return res.json({ url }); @@ -700,25 +676,22 @@ async function getWsUrl(req, res) { // GET/POST /api/ui-settings async function getUiSettings(req, res) { const { checkIfNoneMatch } = require('../middleware/errorHandler'); - + try { - const { HeadObjectCommand } = require('@aws-sdk/client-s3'); - const head = await s3.send(new HeadObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/rt_ui_settings.json' - })).catch(() => null); - - const etag = head?.ETag || null; + const head = await headMeta('bgp_data/rt_ui_settings.json'); + const etag = head?.etag || null; if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); if (checkIfNoneMatch(req, res, etag)) return; - const data = await s3.send(new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: 'bgp_data/rt_ui_settings.json' - })); - const jsonText = await streamToString(data.Body); + let jsonText = '{}'; + try { + const data = await readS3TextObject('bgp_data/rt_ui_settings.json'); + jsonText = data.body; + } catch (e) { + if (e?.code !== 'NoSuchKey') throw e; + } let settings = {}; try { const parsed = JSON.parse(jsonText); @@ -728,11 +701,11 @@ async function getUiSettings(req, res) { } return res.json(settings); } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { + if (error?.code === 'NoSuchKey') { return res.json({}); } - console.error('Error reading ui settings from S3:', error); - return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3'); + console.error('Error reading ui settings:', error); + return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE'); } } @@ -756,8 +729,8 @@ async function postUiSettings(req, res) { const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload); return sendOk(res, meta); } catch (error) { - console.error('Error writing UI settings to S3:', error); - return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) }); + console.error('Error writing UI settings:', error); + return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) }); } } diff --git a/backend/routes/routeOptimizerRoutes.js b/backend/routes/routeOptimizerRoutes.js index ffffca8..52cbaa3 100644 --- a/backend/routes/routeOptimizerRoutes.js +++ b/backend/routes/routeOptimizerRoutes.js @@ -5,6 +5,7 @@ const { sendError } = require('../middleware/errorHandler'); const { readS3TextObject, listS3Objects } = require('../services/s3Service'); +const evobgpClient = require('../services/evobgpClient'); const { readServersFromS3 } = require('./serversRoutes'); const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; @@ -173,9 +174,8 @@ function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community async function loadCommunitiesIndex() { try { - const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null); - if (!raw?.body) return new Map(); - const arr = JSON.parse(raw.body || '[]'); + if (!evobgpClient.isConfigured()) return new Map(); + const arr = await evobgpClient.listCommunities(); const m = new Map(); if (!Array.isArray(arr)) return m; for (const c of arr) { diff --git a/backend/routes/serverConfigsRoutes.js b/backend/routes/serverConfigsRoutes.js index 6d8ce36..568c86d 100644 --- a/backend/routes/serverConfigsRoutes.js +++ b/backend/routes/serverConfigsRoutes.js @@ -5,10 +5,8 @@ * Это нужно для EasySwitch, который работает только с jumphost серверами */ -const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject } = require('../services/s3Service'); +const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); -const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { streamToString } = require('../services/s3Service'); const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers'); const { isValidServerId } = require('../lib/validators'); @@ -41,15 +39,20 @@ function validateJumphostServer(server, i) { // GET /api/server-configs (список только jumphost серверов) async function getServerConfigsList(req, res) { try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); - const etag = head?.ETag || null; + const head = await headMeta(S3_KEY); + const etag = head?.etag || null; if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); if (checkIfNoneMatch(req, res, etag)) return; - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); - const fileContent = await streamToString(data.Body); + let fileContent = '[]'; + try { + const data = await readS3TextObject(S3_KEY); + fileContent = data.body; + } catch (e) { + if (e?.code !== 'NoSuchKey') throw e; + } let allServers = []; try { @@ -113,12 +116,10 @@ async function postServerConfigsList(req, res) { // Читаем текущие данные let allServers = []; try { - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); - const fileContent = await streamToString(data.Body); - allServers = JSON.parse(fileContent); + const data = await readS3TextObject(S3_KEY); + allServers = JSON.parse(data.body); if (!Array.isArray(allServers)) allServers = []; } catch (e) { - // Файл не существует или пустой - это нормально allServers = []; } diff --git a/backend/routes/serversRoutes.js b/backend/routes/serversRoutes.js index 403ef4f..f56924b 100644 --- a/backend/routes/serversRoutes.js +++ b/backend/routes/serversRoutes.js @@ -3,8 +3,7 @@ * Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword */ -const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service'); +const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { encrypt, decrypt } = require('../utils/encryption'); @@ -28,7 +27,7 @@ function validateServer(server, i) { if (!Array.isArray(server.gateways) || server.gateways.length === 0) { return `Server at index ${i} must have gateways for type ${normalizedType}`; } - const primaries = server.gateways.filter(g => g && g.primary); + const primaries = server.gateways.filter((g) => g && g.primary); if (primaries.length !== 1) { return `Server at index ${i} must have exactly one primary gateway`; } @@ -45,24 +44,17 @@ function validateServer(server, i) { async function readServersFromS3() { try { - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); - const body = await streamToString(data.Body); - const parsed = JSON.parse(body || '[]'); + const data = await readS3TextObject(S3_KEY); + const parsed = JSON.parse(data.body || '[]'); return Array.isArray(parsed) ? parsed : []; } catch (e) { - if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return []; + if (e?.code === 'NoSuchKey') return []; throw e; } } async function writeServersToS3(servers) { - const body = JSON.stringify(servers, null, 2); - await s3.send(new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: S3_KEY, - Body: body, - ContentType: 'application/json', - })); + await writeS3JsonObject(S3_KEY, servers); } /** @@ -70,15 +62,15 @@ async function writeServersToS3(servers) { */ async function getServers(req, res) { try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); - const etag = head?.ETag || null; + const head = await headMeta(S3_KEY); + const etag = head?.etag || null; if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); if (checkIfNoneMatch(req, res, etag)) return; const servers = await readServersFromS3(); - const sanitized = servers.map(s => { + const sanitized = servers.map((s) => { const out = { ...s }; const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home'; if (hasMikrotikApi && out.encryptedMikrotikPassword) { @@ -91,11 +83,11 @@ async function getServers(req, res) { }); res.json(sanitized); } catch (error) { - if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { + if (error?.code === 'NoSuchKey') { return res.json([]); } console.error(error); - return sendError(res, 500, 'Error reading servers', 'E_S3'); + return sendError(res, 500, 'Error reading servers', 'E_STORAGE'); } } @@ -115,9 +107,10 @@ async function postServers(req, res) { try { const currentServers = await readServersFromS3(); - const findCurrent = (srv) => currentServers.find(c => - (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip) - ); + const findCurrent = (srv) => + currentServers.find( + (c) => (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip), + ); const toSave = items.map((srv) => { const current = findCurrent(srv); @@ -140,15 +133,15 @@ async function postServers(req, res) { }); await writeServersToS3(toSave); - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); + const meta = await headMeta(S3_KEY); return sendOk(res, { - etag: head?.ETag || null, - lastModified: head?.LastModified ? head.LastModified.toISOString() : null, - contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null, + etag: meta?.etag || null, + lastModified: meta?.lastModified || null, + contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null, }); } catch (error) { console.error(error); - return sendError(res, 500, error.message || 'Error saving servers', 'E_S3'); + return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE'); } } diff --git a/backend/routes/textDataRoutes.js b/backend/routes/textDataRoutes.js index 44b6dd0..daa3f1c 100644 --- a/backend/routes/textDataRoutes.js +++ b/backend/routes/textDataRoutes.js @@ -1,14 +1,10 @@ /** * Общие роуты для текстовых данных (domains, asns, ip-ranges) - * Уменьшают дублирование кода для похожих эндпоинтов */ -const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service'); +const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { splitWhitespace } = require('../utils/helpers'); -const { s3, BUCKET_NAME } = require('../services/s3Service'); -const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { streamToString } = require('../services/s3Service'); // Кэш для countOnly запросов const countOnlyCache = { map: new Map(), ttlMs: 10_000 }; @@ -27,17 +23,10 @@ function setCountOnlyCache(cacheKey, value) { countOnlyCache.map.set(cacheKey, { value, at: Date.now() }); } -/** - * Создать GET эндпоинт для текстовых данных - * @param {string} s3Key - Ключ S3 файла - * @param {function} mapLine - Функция маппинга строки в объект - * @param {function} validate - Функция валидации данных - * @param {string} cachePrefix - Префикс для кэша - */ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) { return async (req, res) => { const { q = '', offset, limit, countOnly, format } = req.query || {}; - + try { if (countOnly === 'true') { const cacheKey = `${cachePrefix}:count:${q}`; @@ -54,24 +43,32 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) { q, offset: Number(offset) || 0, limit: Number(limit) || 0, - mapLine + mapLine, }); if (!validate(items)) { return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); } return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); } else { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); - const etag = head?.ETag || null; + const head = await headMeta(s3Key); + const etag = head?.etag || null; if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); if (checkIfNoneMatch(req, res, etag)) return; - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); - const fileContent = await streamToString(data.Body); - const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim())); - + let fileContent; + try { + const data = await readS3TextObject(s3Key); + fileContent = data.body; + } catch (e) { + if (e?.code === 'NoSuchKey') { + return res.json([]); + } + throw e; + } + const items = fileContent.split('\n').filter((line) => line).map((line) => mapLine(line.trim())); + if (!validate(items)) { return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); } @@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) { res.json([]); } else { console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) }); + return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) }); } } }; } -/** - * Создать POST эндпоинт для текстовых данных - * @param {string} s3Key - Ключ S3 файла - * @param {function} formatLine - Функция форматирования объекта в строку - * @param {function} validate - Функция валидации данных - * @param {function} validateItem - Функция валидации отдельного элемента (опционально) - */ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) { return async (req, res) => { - const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные - - // Поддержка обратной совместимости: принимаем domains | ipRanges | items + const { domains, ipRanges, items, etag } = req.body; + const payload = Array.isArray(domains) ? domains : Array.isArray(ipRanges) @@ -114,13 +103,11 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) { if (!Array.isArray(payload)) { return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); } - - // Валидация с помощью AJV + if (!validate(payload || [])) { return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA'); } - - // Дополнительная валидация элементов, если предоставлена + if (validateItem) { const validationErrors = []; for (let i = 0; i < payload.length; i++) { @@ -129,62 +116,41 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) { validationErrors.push(...errors); } } - + if (validationErrors.length > 0) { return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { - errors: validationErrors.slice(0, 10) + errors: validationErrors.slice(0, 10), }); } } - + const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n'); try { - // Проверка ETag для optimistic concurrency let current = null; - const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; + const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g, '"') : null; try { current = await headS3ObjectEtag(s3Key); - } catch {} - + } catch (_) {} + if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { - const { headMeta } = require('../services/s3Service'); - const meta = await headMeta(s3Key); - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { - currentEtag: current, - meta - }); + return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH'); } - } catch {} - try { const meta = await writeS3TextObject(s3Key, fileContent); return sendOk(res, meta); } catch (error) { console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3', { - error: String(error?.message || error) - }); + return sendError(res, 500, 'Error writing storage', 'E_STORAGE'); } }; } -/** - * Создать роуты для текстовых данных (GET + POST) - */ function createTextDataRoutes(config) { - const { - s3Key, - mapLine, - formatLine, - validate, - validateItem, - cachePrefix - } = config; - + const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config; return { get: createTextDataGET(s3Key, mapLine, validate, cachePrefix), - post: createTextDataPOST(s3Key, formatLine, validate, validateItem) + post: createTextDataPOST(s3Key, formatLine, validate, validateItem), }; } @@ -193,4 +159,3 @@ module.exports = { createTextDataGET, createTextDataPOST, }; - diff --git a/backend/server.js b/backend/server.js index ddb1357..c178699 100644 --- a/backend/server.js +++ b/backend/server.js @@ -15,9 +15,16 @@ const crypto = require('crypto'); // Импорт модулей const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler'); const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager'); -const { createTextDataRoutes } = require('./routes/textDataRoutes'); const { createJsonDataRoutes } = require('./routes/jsonDataRoutes'); -const { splitWhitespace } = require('./utils/helpers'); +const { createFilterRulesRoutes } = require('./routes/filterRulesRoutes'); +const evobgpClient = require('./services/evobgpClient'); +const { + createEvobgpListGET, + createEvobgpDomainsNewPOST, + createEvobgpAsnsPOST, + createEvobgpIpRangesPOST, + createEvobgpLegacyDomainsGET, +} = require('./routes/evobgpReferenceRoutes'); const validators = require('./lib/validators'); const mikrotikValidator = require('./lib/mikrotik-validator'); @@ -231,99 +238,100 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew); const validateAsns = ajv.compile(schemaAsns); const validateIpRanges = ajv.compile(schemaIpRanges); -// === Domains (старая версия) === -const domainsOldRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/domains.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', type: parts[1] || '' }; - }, - formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(), - validate: validateAsns, - cachePrefix: 'domains' -}); +// === Domains (старая версия, поля domain+type — тот же источник, что ASNs) === +const asnValidateItem = (asn, i) => { + const errors = []; + if (!validators.isValidASN(asn.domain)) { + errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`); + } + if (!validators.isValidCommunity(asn.type)) { + errors.push(`Элемент ${i}: неверный community "${asn.type}"`); + } + return errors; +}; + +const domainsOldRoutes = { + get: createEvobgpLegacyDomainsGET({ validate: validateAsns }), + post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }), +}; app.get('/api/domains', domainsOldRoutes.get); -app.post('/api/domains', domainsOldRoutes.post); +app.post('/api/domains', writeLimiter, domainsOldRoutes.post); -// === ASNs === -const asnsRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/asns.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', type: parts[1] || '' }; - }, - formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(), - validate: validateAsns, - validateItem: (asn, i) => { - const errors = []; - if (!validators.isValidASN(asn.domain)) { - errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`); - } - if (!validators.isValidCommunity(asn.type)) { - errors.push(`Элемент ${i}: неверный community "${asn.type}"`); - } - return errors; - }, - cachePrefix: 'asns' -}); +// === ASNs (EvoBGP) === +const asnsRoutes = { + get: createEvobgpListGET({ + cachePrefix: 'asns', + validate: validateAsns, + fetchItems: async () => { + const rows = await evobgpClient.fetchAsnsData(); + return rows.map((r) => ({ domain: r.asn, type: r.community })); + }, + }), + post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }), +}; app.get('/api/asns', asnsRoutes.get); app.post('/api/asns', writeLimiter, asnsRoutes.post); -// === Domains New === -const domainsNewRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/domains_community.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', community: parts[1] || '' }; - }, - formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(), - validate: validateDomainsNew, - validateItem: (d, i) => { - const errors = []; - if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { - errors.push(`Элемент ${i}: неверный домен "${d.domain}"`); - } - if (!validators.isValidCommunity(d.community)) { - errors.push(`Элемент ${i}: неверный community "${d.community}"`); - } - if (!validators.isSafeXSSString(d.domain)) { - errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); - } - return errors; - }, - cachePrefix: 'domains-new' -}); +// === Domains New (EvoBGP) === +const domainsNewRoutes = { + get: createEvobgpListGET({ + cachePrefix: 'domains-new', + validate: validateDomainsNew, + fetchItems: async () => { + const rows = await evobgpClient.fetchDomainsData(); + return rows.map(({ domain, community }) => ({ domain, community })); + }, + }), + post: createEvobgpDomainsNewPOST({ + validate: validateDomainsNew, + validateItem: (d, i) => { + const errors = []; + if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { + errors.push(`Элемент ${i}: неверный домен "${d.domain}"`); + } + if (!validators.isValidCommunity(d.community)) { + errors.push(`Элемент ${i}: неверный community "${d.community}"`); + } + if (!validators.isSafeXSSString(d.domain)) { + errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); + } + return errors; + }, + }), +}; app.get('/api/domains-new', domainsNewRoutes.get); app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post); -// === IP Ranges === -const ipRangesRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/ips.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { ipRange: parts[0] || '', community: parts[1] || '' }; - }, - formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(), - validate: validateIpRanges, - validateItem: (ip, i) => { - const errors = []; - const ipStr = String(ip.ipRange || '').trim(); - const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); - const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); - - if (!isValidCIDR && !isValidIP) { - errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); - } - if (!validators.isValidCommunity(ip.community)) { - errors.push(`Элемент ${i}: неверный community "${ip.community}"`); - } - return errors; - }, - cachePrefix: 'ip-ranges' -}); +// === IP Ranges (EvoBGP) === +const ipRangesRoutes = { + get: createEvobgpListGET({ + cachePrefix: 'ip-ranges', + validate: validateIpRanges, + fetchItems: async () => { + const rows = await evobgpClient.fetchIpRangesData(); + return rows.map(({ ipRange, community }) => ({ ipRange, community })); + }, + }), + post: createEvobgpIpRangesPOST({ + validate: validateIpRanges, + validateItem: (ip, i) => { + const errors = []; + const ipStr = String(ip.ipRange || '').trim(); + const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); + const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); + if (!isValidCIDR && !isValidIP) { + errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); + } + if (!validators.isValidCommunity(ip.community)) { + errors.push(`Элемент ${i}: неверный community "${ip.community}"`); + } + return errors; + }, + }), +}; app.get('/api/ip-ranges', ipRangesRoutes.get); app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post); @@ -360,24 +368,21 @@ const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) => app.get('/api/billing', billingRoutes.get); app.post('/api/billing', billingRoutes.post); -// Filters -const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => { +// Filters (SQL app_filter_rules, scope global) +const filterItemValidator = (filter, i) => { if (!filter.community || !filter.gateway) { return `Filter at index ${i} is missing required fields`; } return null; -}); +}; + +const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator); app.get('/api/filters', filtersRoutes2.get); app.post('/api/filters', filtersRoutes2.post); -// Simple Filters -const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => { - if (!filter.community || !filter.gateway) { - return `Filter at index ${i} is missing required fields`; - } - return null; -}); +// Simple Filters (app_filter_rules, scope simple) +const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator); app.get('/api/simple-filters', simpleFiltersRoutes.get); app.post('/api/simple-filters', simpleFiltersRoutes.post); @@ -508,7 +513,7 @@ app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats); // === ALERTS (агрегация проблем: серверы, ресурсы роутеров) === app.get('/api/alerts', alertsRoutes.getAlerts); -// === MIKROTIK BACKUPS (S3) === +// === MIKROTIK BACKUPS (локальная ФС) === app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup); app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup); diff --git a/backend/services/backupFsService.js b/backend/services/backupFsService.js new file mode 100644 index 0000000..75010e9 --- /dev/null +++ b/backend/services/backupFsService.js @@ -0,0 +1,103 @@ +/** + * Бэкапы MikroTik на локальной ФС (вместо S3). + * Логические ключи сохраняют вид backups/mikrotik/{serverId}/{ts}-{rand}.rsc + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const BACKUP_PREFIX = 'backups/mikrotik'; + +function backupRootDir() { + const raw = process.env.MIKROTIK_BACKUP_DIR || path.join(__dirname, '..', 'data', 'backups', 'mikrotik'); + if (!fs.existsSync(raw)) { + fs.mkdirSync(raw, { recursive: true }); + } + return raw; +} + +function parseBackupKey(key) { + const m = String(key || '').match(/^backups\/mikrotik\/([^/]+)\/(.+)$/); + if (!m) return null; + return { safeId: m[1], fileName: m[2] }; +} + +function fsPathForKey(key) { + const p = parseBackupKey(key); + if (!p) return null; + return path.join(backupRootDir(), p.safeId, p.fileName); +} + +function buildBackupKey(serverId, createdAt, suffix) { + const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_'); + const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z'); + const rand = suffix || crypto.randomBytes(3).toString('hex'); + return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`; +} + +function etagForContent(content) { + const h = crypto.createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex'); + return `"${h}"`; +} + +async function writeBackupFile(key, content, contentType = 'text/plain') { + const p = parseBackupKey(key); + if (!p) throw new Error('Invalid backup key'); + const dir = path.join(backupRootDir(), p.safeId); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const fp = path.join(dir, p.fileName); + await fs.promises.writeFile(fp, String(content), 'utf8'); + const st = await fs.promises.stat(fp); + const etag = etagForContent(String(content)); + return { + etag, + lastModified: st.mtime.toISOString(), + contentLength: st.size, + }; +} + +async function readBackupFile(key) { + const fp = fsPathForKey(key); + if (!fp) throw new Error('Invalid backup key'); + const body = await fs.promises.readFile(fp, 'utf8'); + const st = await fs.promises.stat(fp); + const etag = etagForContent(body); + return { + body, + etag, + lastModified: st.mtime.toISOString(), + contentLength: st.size, + }; +} + +async function listBackupObjects(serverId, maxKeys = 200) { + const safeId = String(serverId || '').replace(/[^a-zA-Z0-9._-]/g, '_'); + const dir = path.join(backupRootDir(), safeId); + if (!fs.existsSync(dir)) return []; + const names = await fs.promises.readdir(dir); + const out = []; + for (const name of names) { + if (out.length >= maxKeys) break; + const fp = path.join(dir, name); + const st = await fs.promises.stat(fp); + if (!st.isFile()) continue; + const key = `${BACKUP_PREFIX}/${safeId}/${name}`; + out.push({ + key, + size: st.size, + lastModified: st.mtime.toISOString(), + etag: etagForContent(await fs.promises.readFile(fp, 'utf8')), + }); + } + return out; +} + +module.exports = { + BACKUP_PREFIX, + buildBackupKey, + writeBackupFile, + readBackupFile, + listBackupObjects, + backupRootDir, +}; diff --git a/backend/services/blobStorage.js b/backend/services/blobStorage.js new file mode 100644 index 0000000..ab4c5df --- /dev/null +++ b/backend/services/blobStorage.js @@ -0,0 +1,167 @@ +/** + * SQLite blob store (logical S3 keys → rows in `blobs`). + */ + +const crypto = require('crypto'); +const { openDatabase } = require('../db/sqliteDb'); + +function sha256Etag(buf) { + const h = crypto.createHash('sha256').update(buf).digest('hex'); + return `"${h}"`; +} + +function namespaceFromKey(objectKey) { + const k = String(objectKey || ''); + if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') || + k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) { + return 'cache'; + } + if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) { + return 'per_server'; + } + if (k === 'mikrotik-frouting-config.txt') { + return 'derived'; + } + return 'config'; +} + +function isVersionedKey(db, objectKey) { + const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey); + return Boolean(row); +} + +function trimVersions(db, objectKey, keep = 50) { + db.prepare( + `DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN ( + SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ? + )` + ).run(objectKey, objectKey, keep); +} + +/** + * @param {Buffer|string} body + */ +function writeBlobTx(db, objectKey, body, contentType) { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8'); + const ns = namespaceFromKey(objectKey); + const etag = sha256Etag(buf); + const now = Date.now(); + const size = buf.length; + + const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey); + + const run = () => { + if (existing && isVersionedKey(db, objectKey)) { + const oldBuf = existing.body; + db.prepare( + `INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at) + VALUES (?, ?, ?, ?, ?)` + ).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at); + trimVersions(db, objectKey); + } + + if (existing) { + db.prepare( + `UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ? + WHERE object_key = ?` + ).run(ns, contentType, buf, size, etag, now, objectKey); + } else { + db.prepare( + `INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run(objectKey, ns, contentType, buf, size, etag, now, now); + } + }; + + db.transaction(run)(); + return { + etag, + lastModified: new Date(now).toISOString(), + contentLength: size, + }; +} + +function readBlob(objectKey) { + const db = openDatabase(); + const row = db.prepare( + 'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?' + ).get(objectKey); + if (!row) return null; + const bodyStr = row.body.toString('utf8'); + return { + body: bodyStr, + etag: row.etag, + lastModified: new Date(row.updated_at).toISOString(), + contentLength: row.byte_size, + }; +} + +function headMetaRow(objectKey) { + const db = openDatabase(); + const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey); + if (!row) { + return { etag: null, lastModified: null, contentLength: null }; + } + return { + etag: row.etag, + lastModified: new Date(row.updated_at).toISOString(), + contentLength: row.byte_size, + }; +} + +function headEtag(objectKey) { + const m = headMetaRow(objectKey); + return m.etag || undefined; +} + +function deleteBlob(objectKey) { + const db = openDatabase(); + db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey); +} + +function listByPrefix(prefix, maxKeys = 100) { + const db = openDatabase(); + const rows = db.prepare( + `SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs + WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?` + ).all(prefix, prefix + '\uffff', maxKeys); + return rows.map((r) => ({ + key: r.key, + size: r.size, + lastModified: new Date(r.updated_at).toISOString(), + etag: r.etag, + })); +} + +function listVersions(objectKey, maxKeys = 50) { + const db = openDatabase(); + return db.prepare( + `SELECT id, object_key, byte_size, etag, created_at FROM blob_versions + WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?` + ).all(objectKey, maxKeys); +} + +function rollbackToVersionId(objectKey, versionId) { + const db = openDatabase(); + const vid = Number(versionId); + if (!Number.isFinite(vid)) throw new Error('Invalid versionId'); + const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey); + if (!ver) throw new Error('Version not found'); + const buf = ver.body; + const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain'; + writeBlobTx(db, objectKey, buf, contentType); + return headMetaRow(objectKey); +} + +module.exports = { + namespaceFromKey, + writeBlobTx, + readBlob, + headMetaRow, + headEtag, + deleteBlob, + listByPrefix, + listVersions, + rollbackToVersionId, + sha256Etag, +}; diff --git a/backend/services/evobgpClient.js b/backend/services/evobgpClient.js new file mode 100644 index 0000000..221b019 --- /dev/null +++ b/backend/services/evobgpClient.js @@ -0,0 +1,426 @@ +/** + * Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js + */ + +const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes'); + +const PAGE_LIMIT = 500; +const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000; + +function getBaseAndToken() { + const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || ''); + const token = String(process.env.EVOBGP_API_TOKEN || '').trim(); + return { base, token }; +} + +function isConfigured() { + const { base, token } = getBaseAndToken(); + return Boolean(base && token); +} + +async function evobgpRequest(method, pathname, { query, body } = {}) { + const { base, token } = getBaseAndToken(); + if (!base || !token) { + const err = new Error('EvoBGP not configured'); + err.code = 'E_EVOBGP_NOT_CONFIGURED'; + throw err; + } + const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`); + if (query && typeof query === 'object') { + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null) u.searchParams.set(k, String(v)); + } + } + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS); + try { + const res = await fetch(u.toString(), { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: ctrl.signal, + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = text; + } + if (!res.ok) { + const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText); + err.code = 'E_EVOBGP_HTTP'; + err.status = res.status; + err.body = data; + throw err; + } + return data; + } finally { + clearTimeout(t); + } +} + +async function paginate(path, params = {}) { + const items = []; + let cursor = null; + while (true) { + const res = await evobgpRequest('GET', path, { + query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, + }); + const chunk = Array.isArray(res?.items) ? res.items : []; + items.push(...chunk); + if (!res?.has_more || !res?.next_cursor) break; + cursor = res.next_cursor; + } + return items; +} + +async function fetchRouterListsCatalog() { + return evobgpRequest('GET', '/router-lists/catalog'); +} + +function getCatalogItems(section) { + if (!section || typeof section !== 'object') return []; + return Array.isArray(section.items) ? section.items : []; +} + +async function listCommunities() { + let communities = []; + try { + const catalog = await fetchRouterListsCatalog(); + communities = getCatalogItems(catalog.communities); + } catch { + communities = await paginate('/communities'); + } + return communities.map((c) => ({ + id: c.id, + value: String(c.community || ''), + name: String(c.title || ''), + description: '', + tags: [], + color: '', + icon: '', + })); +} + +async function getOrCreateModule(moduleType, defaultName) { + const modules = await paginate('/modules', { type: moduleType }); + if (modules.length > 0) return modules[0]; + const created = await evobgpRequest('POST', '/modules', { + body: { type: moduleType, name: defaultName, enabled: true }, + }); + return created?.data || created; +} + +function normalizeList(items, keyName) { + const out = []; + const seen = new Set(); + for (const item of items || []) { + const key = String(item?.[keyName] || '').trim().toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push({ ...item, [keyName]: key }); + } + return out; +} + +async function buildCommunityMaps() { + const communities = await listCommunities(); + const byId = new Map(communities.map((c) => [c.id, c.value])); + const byValue = new Map(communities.map((c) => [c.value, c.id])); + return { communities, byId, byValue }; +} + +function mapCommunityToValue(communityId, byId) { + if (!communityId) return ''; + return byId.get(communityId) || String(communityId); +} + +async function syncEntries({ + moduleType, + moduleName, + listPath, + keyName, + createBody, + patchBody, + currentItems, + originalItems, + requireCommunity = false, +}) { + const module = await getOrCreateModule(moduleType, moduleName); + const moduleId = module.id; + const { byId, byValue } = await buildCommunityMaps(); + const existing = await paginate(`/modules/${moduleId}/${listPath}`); + + const existingByKey = new Map(); + for (const row of existing) { + const entryKey = String(row?.[keyName] || '').trim().toLowerCase(); + if (!entryKey) continue; + existingByKey.set(entryKey, row); + } + + const normalizedCurrent = normalizeList(currentItems, keyName); + const normalizedOriginal = normalizeList(originalItems, keyName); + const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i])); + const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase())); + + for (const key of originalKeys) { + if (!currentMap.has(key) && existingByKey.has(key)) { + const row = existingByKey.get(key); + await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`); + existingByKey.delete(key); + } + } + + for (const [key, item] of currentMap.entries()) { + const rawCommunity = String(item.community || '').trim(); + const communityId = byValue.get(rawCommunity) || null; + if (requireCommunity && !communityId) { + throw new Error(`Community "${rawCommunity}" не найдена в справочнике`); + } + + const existingRow = existingByKey.get(key); + if (!existingRow) { + await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) }); + continue; + } + + const currentCommunity = mapCommunityToValue(existingRow.community_id, byId); + if (currentCommunity !== rawCommunity) { + await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, { + body: patchBody(item, communityId), + }); + } + } +} + +async function fetchDomainsData() { + try { + const catalog = await fetchRouterListsCatalog(); + const communities = getCatalogItems(catalog.communities); + const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); + const rows = getCatalogItems(catalog.domains); + return rows + .map((row) => ({ + id: row?.entry?.id || row?.id || '', + domain: String(row?.entry?.fqdn || row?.fqdn || '') + .trim() + .toLowerCase(), + community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId), + })) + .filter((row) => row.domain); + } catch { + const module = await getOrCreateModule('DOMAINS', 'Domains'); + const { byId } = await buildCommunityMaps(); + const rows = await paginate(`/modules/${module.id}/domain-entries`); + return rows.map((row) => ({ + id: row.id, + domain: String(row.fqdn || '') + .trim() + .toLowerCase(), + community: mapCommunityToValue(row.community_id, byId), + })); + } +} + +async function fetchIpRangesData() { + try { + const catalog = await fetchRouterListsCatalog(); + const communities = getCatalogItems(catalog.communities); + const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); + const rows = getCatalogItems(catalog.ip_ranges); + return rows + .map((row) => ({ + id: row?.entry?.id || row?.id || '', + ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(), + community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId), + })) + .filter((row) => row.ipRange); + } catch { + const module = await getOrCreateModule('IP_RANGES', 'IP ranges'); + const { byId } = await buildCommunityMaps(); + const rows = await paginate(`/modules/${module.id}/ip-range-entries`); + return rows.map((row) => ({ + id: row.id, + ipRange: String(row.prefix || '').trim(), + community: mapCommunityToValue(row.community_id, byId), + })); + } +} + +async function fetchAsnsData() { + try { + const catalog = await fetchRouterListsCatalog(); + const communities = getCatalogItems(catalog.communities); + const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); + const rows = getCatalogItems(catalog.asns); + return rows + .map((row) => ({ + id: row?.entry?.id || row?.id || '', + asn: String(row?.entry?.asn || row?.asn || '').trim(), + community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId), + })) + .filter((row) => row.asn); + } catch { + const module = await getOrCreateModule('AS_PREFIXES', 'ASNs'); + const { byId } = await buildCommunityMaps(); + const rows = await paginate(`/modules/${module.id}/as-entries`); + return rows.map((row) => ({ + id: row.id, + asn: String(row.asn || '').trim(), + community: mapCommunityToValue(row.community_id, byId), + })); + } +} + +/** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */ +async function fetchAutoUrlsData() { + try { + const catalog = await fetchRouterListsCatalog(); + for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) { + const sec = catalog[name]; + const items = getCatalogItems(sec); + if (items.length) { + const communities = getCatalogItems(catalog.communities); + const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); + return items.map((row) => { + const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim(); + const community = mapCommunityToValue( + row?.entry?.community_id || row?.community_id || null, + byId, + ); + return { url, community }; + }).filter((r) => r.url); + } + } + } catch (_) {} + const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS'; + try { + const module = await getOrCreateModule(modType, 'Auto URLs'); + const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries'; + const rows = await paginate(`/modules/${module.id}/${listPath}`); + const { byId } = await buildCommunityMaps(); + return rows.map((row) => ({ + url: String(row.url || row.fqdn || '').trim(), + community: mapCommunityToValue(row.community_id, byId), + })).filter((r) => r.url); + } catch { + return []; + } +} + +async function saveDomainsData(currentItems, originalItems) { + await syncEntries({ + moduleType: 'DOMAINS', + moduleName: 'Domains', + listPath: 'domain-entries', + keyName: 'domain', + currentItems, + originalItems, + createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }), + patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }), + }); +} + +async function saveIpRangesData(currentItems, originalItems) { + await syncEntries({ + moduleType: 'IP_RANGES', + moduleName: 'IP ranges', + listPath: 'ip-range-entries', + keyName: 'ipRange', + currentItems, + originalItems, + requireCommunity: true, + createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }), + patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }), + }); +} + +async function saveAsnsData(currentItems, originalItems) { + await syncEntries({ + moduleType: 'AS_PREFIXES', + moduleName: 'ASNs', + listPath: 'as-entries', + keyName: 'asn', + currentItems, + originalItems, + createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }), + patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }), + }); +} + +async function saveCommunitiesData(currentItems) { + const existing = await paginate('/communities'); + const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c])); + const nextByValue = new Map(); + for (const item of currentItems || []) { + const value = String(item?.value || '').trim(); + if (!value) continue; + nextByValue.set(value, item); + } + + for (const [value, row] of existingByValue.entries()) { + if (!nextByValue.has(value)) { + await evobgpRequest('DELETE', `/communities/${row.id}`); + } + } + + for (const [value, item] of nextByValue.entries()) { + const title = String(item?.name || '').trim(); + const row = existingByValue.get(value); + if (!row) { + await evobgpRequest('POST', '/communities', { body: { community: value, title } }); + continue; + } + if (String(row.title || '') !== title) { + await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } }); + } + } +} + +async function saveAutoUrlsData(urls) { + const originalItems = await fetchAutoUrlsData(); + const list = (urls || []).map((u) => ({ + url: String(u?.url || '').trim(), + community: String(u?.community || '').trim(), + })).filter((u) => u.url && u.community); + const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS'; + const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries'; + const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url'; + const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community })); + const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community })); + await syncEntries({ + moduleType: modType, + moduleName: 'Auto URLs', + listPath, + keyName, + currentItems, + originalItems: origMapped, + requireCommunity: true, + createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }), + patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }), + }); +} + +module.exports = { + isConfigured, + evobgpRequest, + paginate, + fetchRouterListsCatalog, + listCommunities, + fetchDomainsData, + fetchIpRangesData, + fetchAsnsData, + fetchAutoUrlsData, + saveCommunitiesData, + saveDomainsData, + saveIpRangesData, + saveAsnsData, + saveAutoUrlsData, + buildCommunityMaps, + mapCommunityToValue, +}; diff --git a/backend/services/filterRulesStorage.js b/backend/services/filterRulesStorage.js new file mode 100644 index 0000000..fed1860 --- /dev/null +++ b/backend/services/filterRulesStorage.js @@ -0,0 +1,124 @@ +/** + * Глобальные и simple-фильтры в таблице app_filter_rules (не в blobs JSON). + */ + +const crypto = require('crypto'); +const { openDatabase } = require('../db/sqliteDb'); + +/** @typedef {'global'|'simple'} FilterScope */ + +function assertScope(scope) { + if (scope !== 'global' && scope !== 'simple') throw new Error('Invalid filter scope'); +} + +function mtimeMetaKey(scope) { + return `filter_rules_mtime_${scope}`; +} + +function sha256EtagFromString(s) { + const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); + return `"${h}"`; +} + +function rowToItem(row) { + let extra = {}; + try { + extra = JSON.parse(row.extra_json || '{}'); + if (!extra || typeof extra !== 'object' || Array.isArray(extra)) extra = {}; + } catch { + extra = {}; + } + const out = { + community: row.community, + gateway: row.gateway, + description: row.description || '', + }; + for (const k of Object.keys(extra).sort()) { + if (k === 'community' || k === 'gateway' || k === 'description') continue; + out[k] = extra[k]; + } + return out; +} + +/** + * @param {FilterScope} scope + */ +function listRules(scope) { + assertScope(scope); + const db = openDatabase(); + const rows = db + .prepare( + `SELECT community, gateway, description, extra_json FROM app_filter_rules WHERE scope = ? ORDER BY position ASC`, + ) + .all(scope); + return rows.map(rowToItem); +} + +/** + * @param {FilterScope} scope + * @returns {{ etag: string, lastModified: string|null, contentLength: number }} + */ +function headMetaForScope(scope) { + assertScope(scope); + const items = listRules(scope); + const body = JSON.stringify(items); + const etag = sha256EtagFromString(body); + const contentLength = Buffer.byteLength(body, 'utf8'); + const db = openDatabase(); + const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(mtimeMetaKey(scope)); + const lastModified = row?.value ? new Date(Number(row.value)).toISOString() : null; + return { etag, lastModified, contentLength }; +} + +/** + * @param {FilterScope} scope + * @param {any[]} items + * @param {{ validateItem?: (item: any, i: number) => string|null }} [opts] + */ +function replaceRules(scope, items, opts = {}) { + assertScope(scope); + const { validateItem } = opts; + if (!Array.isArray(items)) throw new Error('items must be an array'); + if (validateItem) { + for (let i = 0; i < items.length; i++) { + const err = validateItem(items[i], i); + if (err) { + const e = new Error(err); + e.code = 'E_SCHEMA'; + throw e; + } + } + } + const db = openDatabase(); + const now = Date.now(); + const del = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?'); + const ins = db.prepare( + `INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + const metaUpsert = db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)'); + + const run = () => { + del.run(scope); + for (let i = 0; i < items.length; i++) { + const it = items[i] || {}; + const community = String(it.community || '').trim(); + const gateway = String(it.gateway || '').trim(); + const description = String(it.description || '').trim(); + const extra = { ...it }; + delete extra.community; + delete extra.gateway; + delete extra.description; + ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now); + } + metaUpsert.run(mtimeMetaKey(scope), String(now)); + }; + db.transaction(run)(); + return headMetaForScope(scope); +} + +module.exports = { + listRules, + headMetaForScope, + replaceRules, +}; diff --git a/backend/services/mikrotikBackupScheduler.js b/backend/services/mikrotikBackupScheduler.js index 5232cb5..09519e4 100644 --- a/backend/services/mikrotikBackupScheduler.js +++ b/backend/services/mikrotikBackupScheduler.js @@ -4,7 +4,7 @@ * Работает внутри Node-процесса backend: * - периодически обходит jumphost-сервера из servers.json * - вызывает RouterOS REST API /rest/export (compact) - * - сохраняет конфиг в S3 через saveBackupForServer + * - сохраняет конфиг на диск через saveBackupForServer * * Управляется через переменные окружения: * - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true) @@ -131,7 +131,7 @@ async function runBackupOnce(logger) { log.info( { component: 'mikrotik-backup', serverId: id, key: result.key }, - 'Automatic backup saved to S3', + 'Automatic backup saved to disk', ); } catch (err) { log.error( diff --git a/backend/services/s3Service.js b/backend/services/s3Service.js index 2e130f2..a766e30 100644 --- a/backend/services/s3Service.js +++ b/backend/services/s3Service.js @@ -1,58 +1,35 @@ /** - * Сервис для работы с S3 (Yandex Object Storage) - * Централизованные операции чтения/записи/кэширования + * Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами. + * Справочники EvoBGP не хранятся здесь — см. evobgpClient.js */ -const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3'); -const { NodeHttpHandler } = require('@smithy/node-http-handler'); -const http = require('http'); -const https = require('https'); +const { openDatabase } = require('../db/sqliteDb'); +const blobStorage = require('./blobStorage'); -// S3 Client setup -const s3 = new S3Client({ - endpoint: 'https://storage.yandexcloud.net', - region: process.env.AWS_REGION, - forcePathStyle: true, - maxAttempts: 3, - requestHandler: new NodeHttpHandler({ - httpAgent: new http.Agent({ keepAlive: true }), - httpsAgent: new https.Agent({ keepAlive: true }) - }), - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY, - } -}); +openDatabase(); -const BUCKET_NAME = process.env.S3_BUCKET_NAME; +const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite'; -// In-memory cache const cache = { text: new Map(), head: new Map(), max: 100, - ttlMs: 30_000 + ttlMs: 30_000, }; -/** - * Конвертирует stream в строку - */ async function streamToString(stream) { - if (!stream) return ''; - if (typeof stream.transformToString === 'function') { - return await stream.transformToString(); - } - return await new Promise((resolve, reject) => { - let chunks = []; - stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c)))); - stream.once('error', reject); - stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); - }); + if (!stream) return ''; + if (typeof stream.transformToString === 'function') { + return await stream.transformToString(); + } + return await new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c)))); + stream.once('error', reject); + stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + }); } -/** - * Получить значение из кэша - */ function getCache(map, key) { const v = map.get(key); if (!v) return null; @@ -63,9 +40,6 @@ function getCache(map, key) { return v.value; } -/** - * Установить значение в кэш - */ function setCache(map, key, value) { if (map.size >= cache.max) { const firstKey = map.keys().next().value; @@ -74,213 +48,115 @@ function setCache(map, key, value) { map.set(key, { value, at: Date.now() }); } -/** - * Инвалидировать кэш для ключа - */ function invalidateCacheForKey(key) { - try { cache.text.delete(key); } catch {} - try { cache.head.delete(key); } catch {} -} - -/** - * Прочитать текстовый объект из S3 - */ -async function readS3TextObject(key, s3Duration = null) { - const cached = getCache(cache.text, key); - if (cached) return cached; - - const s3Start = Date.now(); - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - if (s3Duration) { - try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {} - } - - const out = { - body: await streamToString(data.Body), - etag: data.ETag || undefined, - lastModified: data.LastModified ? data.LastModified.toISOString() : undefined, - contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined - }; - setCache(cache.text, key, out); - return out; -} - -/** - * Получить ETag объекта из S3 - */ -async function headS3ObjectEtag(key, s3Duration = null) { - const cached = getCache(cache.head, key); - if (cached && cached.etag) return cached.etag; - - const s3Start = Date.now(); - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - if (s3Duration) { - try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {} - } - - setCache(cache.head, key, { etag: head.ETag || undefined }); - return head.ETag || undefined; -} - -/** - * Получить метаданные объекта (etag, lastModified, contentLength) - */ -async function headMeta(key) { try { - const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - return { - etag: h.ETag || null, - lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null, - contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null, - }; - } catch (e) { - return { etag: null, lastModified: null, contentLength: null }; + cache.text.delete(key); + } catch (_) {} + try { + cache.head.delete(key); + } catch (_) {} +} + +async function readS3TextObject(key) { + const cached = getCache(cache.text, key); + if (cached) return cached; + const row = blobStorage.readBlob(key); + if (!row) { + const err = new Error('NoSuchKey'); + err.code = 'NoSuchKey'; + throw err; } -} - -/** - * Записать текстовый объект в S3 - */ -async function writeS3TextObject(key, content, contentType = 'text/plain') { - await s3.send(new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: content, - ContentType: contentType, - })); - invalidateCacheForKey(key); - return await headMeta(key); -} - -/** - * Записать JSON объект в S3 - */ -async function writeS3JsonObject(key, data) { - return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json'); -} - -/** - * Удалить объект из S3 - */ -async function deleteS3Object(key) { - await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - invalidateCacheForKey(key); -} - -/** - * Список объектов в S3 по префиксу - * Используется для истории/бэкапов (количество ограничено для безопасности) - */ -async function listS3Objects(prefix, { maxKeys = 100 } = {}) { - const out = []; - let continuationToken = undefined; - - while (out.length < maxKeys) { - const resp = await s3.send(new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - Prefix: prefix, - ContinuationToken: continuationToken, - MaxKeys: Math.min(1000, maxKeys - out.length), - })); - - const contents = resp.Contents || []; - for (const obj of contents) { - out.push({ - key: obj.Key, - size: typeof obj.Size === 'number' ? obj.Size : null, - lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null, - etag: obj.ETag || null, - }); - if (out.length >= maxKeys) break; - } - - if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) { - break; - } - continuationToken = resp.NextContinuationToken; - } - + const out = { + body: row.body, + etag: row.etag, + lastModified: row.lastModified, + contentLength: row.contentLength, + }; + setCache(cache.text, key, out); return out; } -/** - * Потоковое чтение с пагинацией больших текстовых файлов - */ -async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) { - return new Promise(async (resolve, reject) => { - let total = 0; - const items = []; - let sent = 0; - let buffered = ''; - - const matchesQuery = (line) => { - if (!q) return true; - return line.toLowerCase().includes(String(q).toLowerCase()); - }; - - try { - const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - const stream = resp.Body; - - if (!stream || typeof stream.on !== 'function') { - const text = await streamToString(resp.Body); - const lines = text.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = String(lines[i] || '').trim(); - if (!line) continue; - if (!matchesQuery(line)) continue; - total++; - const pos = total - 1; - if (limit > 0) { - if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; } - } else { - items.push(mapLine(line)); - } - } - return resolve({ items, total }); - } - - stream.on('data', (chunk) => { - buffered += chunk.toString('utf-8'); - let lines = buffered.split('\n'); - buffered = lines.pop(); - for (const lnRaw of lines) { - const line = lnRaw.trim(); - if (!line) continue; - if (!matchesQuery(line)) continue; - total++; - const pos = total - 1; - if (limit > 0) { - if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; } - } else { - items.push(mapLine(line)); - } - } - }); - - stream.on('end', () => { - const last = (buffered || '').trim(); - if (last) { - if (!q || last.toLowerCase().includes(String(q).toLowerCase())) { - total++; - if (limit > 0) { - const pos = total - 1; - if (pos >= offset && items.length < limit) items.push(mapLine(last)); - } else { - items.push(mapLine(last)); - } - } - } - resolve({ items, total }); - }); - - stream.on('error', reject); - } catch (e) { - reject(e); - } - }); +async function headS3ObjectEtag(key) { + const cached = getCache(cache.head, key); + if (cached && cached.etag) return cached.etag; + const etag = blobStorage.headEtag(key); + if (etag) setCache(cache.head, key, { etag }); + return etag; } +async function headMeta(key) { + return blobStorage.headMetaRow(key); +} + +async function writeS3TextObject(key, content, contentType = 'text/plain') { + const db = openDatabase(); + blobStorage.writeBlobTx(db, key, content, contentType); + invalidateCacheForKey(key); + return headMeta(key); +} + +async function writeS3JsonObject(key, data) { + return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json'); +} + +async function deleteS3Object(key) { + blobStorage.deleteBlob(key); + invalidateCacheForKey(key); +} + +async function listS3Objects(prefix, { maxKeys = 100 } = {}) { + return blobStorage.listByPrefix(prefix, maxKeys); +} + +async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) { + let text = ''; + try { + const row = await readS3TextObject(key); + text = row.body || ''; + } catch (e) { + if (e?.code === 'NoSuchKey') { + return { items: [], total: 0 }; + } + throw e; + } + + const lines = text.split('\n'); + let total = 0; + const items = []; + const qstr = q ? String(q).toLowerCase() : ''; + const matchesQuery = (line) => !qstr || line.toLowerCase().includes(qstr); + + for (let i = 0; i < lines.length; i++) { + const line = String(lines[i] || '').trim(); + if (!line) continue; + if (!matchesQuery(line)) continue; + total++; + const pos = total - 1; + if (limit > 0) { + if (pos >= offset && items.length < limit) items.push(mapLine(line)); + } else { + items.push(mapLine(line)); + } + } + return { items, total }; +} + +function listBlobVersionsForKey(key, maxKeys = 50) { + return blobStorage.listVersions(key, maxKeys); +} + +function rollbackBlobVersion(key, versionId) { + openDatabase(); + return blobStorage.rollbackToVersionId(key, versionId); +} + +/** Заглушка: прямой вызов S3 SDK больше не используется. */ +const s3 = { + send() { + throw new Error('S3 SDK removed: use readS3TextObject / writeS3TextObject or evobgpClient'); + }, +}; + module.exports = { s3, BUCKET_NAME, @@ -294,5 +170,6 @@ module.exports = { streamPaginatedText, invalidateCacheForKey, listS3Objects, + listBlobVersionsForKey, + rollbackBlobVersion, }; - diff --git a/backend/utils/helpers.js b/backend/utils/helpers.js index 1785ee2..623a2af 100644 --- a/backend/utils/helpers.js +++ b/backend/utils/helpers.js @@ -39,14 +39,15 @@ function mapAjvErrors(errors) { } /** - * Маппинг UI ресурсов на S3 ключи (для истории версий) + * Маппинг UI ресурсов на ключи в SQLite blobs (только локально версионируемые объекты). + * Справочники EvoBGP (domains-new / asns / ip-ranges) в истории не участвуют. */ function resourceToKey(resource) { switch (resource) { - case 'domains-new': return 'bgp_data/domains_community.txt'; - case 'ip-ranges': return 'bgp_data/ips.txt'; - case 'asns': return 'bgp_data/asns.txt'; - default: return null; + case 'servers': + return 'servers.json'; + default: + return null; } } diff --git a/backend/utils/s3Helpers.js b/backend/utils/s3Helpers.js index b4a89ef..cbbcd3d 100644 --- a/backend/utils/s3Helpers.js +++ b/backend/utils/s3Helpers.js @@ -1,42 +1,37 @@ /** - * Универсальные хелперы для работы с S3 - * Устраняют дублирование кода в routes + * Универсальные хелперы для JSON/текста из локального storage (SQLite) */ -const { s3, BUCKET_NAME } = require('../services/s3Service'); -const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { streamToString } = require('../services/s3Service'); +const { headMeta, readS3TextObject } = require('../services/s3Service'); const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler'); /** - * Универсальный GET для JSON объектов из S3 с заголовками - * @param {string} s3Key - Ключ S3 - * @param {object} req - Express request - * @param {object} res - Express response - * @param {object} options - Опции - * @param {function} options.transform - Функция трансформации данных - * @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла + * Универсальный GET для JSON объектов с заголовками */ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) { const { transform = (data) => data, defaultValue = [] } = options; - + try { - // Получаем HEAD для установки заголовков - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); - const etag = head?.ETag || null; - - // Устанавливаем заголовки + const head = await headMeta(s3Key); + const etag = head?.etag || null; + if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - - // Проверяем If-None-Match + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); + if (checkIfNoneMatch(req, res, etag)) return; - // Получаем объект - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); - const fileContent = await streamToString(data.Body); - + let fileContent; + try { + const data = await readS3TextObject(s3Key); + fileContent = data.body; + } catch (e) { + if (e?.code === 'NoSuchKey') { + return res.json(transform(defaultValue)); + } + throw e; + } + let parsed; try { parsed = JSON.parse(fileContent); @@ -47,55 +42,47 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) { console.error(`Error parsing ${s3Key}:`, parseError); parsed = defaultValue; } - - // Применяем трансформацию + const result = transform(parsed); - res.json(result); } catch (error) { - if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json(defaultValue); - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } + console.error(error); + return sendError(res, 500, 'Error reading storage', 'E_STORAGE'); } } /** - * Универсальный GET для текстовых объектов из S3 с заголовками - * @param {string} s3Key - Ключ S3 - * @param {object} req - Express request - * @param {object} res - Express response - * @param {object} options - Опции - * @param {function} options.transform - Функция трансформации текста - * @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла + * Универсальный GET для текстовых объектов с заголовками */ async function getS3TextWithHeaders(s3Key, req, res, options = {}) { const { transform = (text) => ({ config: text }), defaultValue = '' } = options; - + try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); - const etag = head?.ETag || null; - + const head = await headMeta(s3Key); + const etag = head?.etag || null; + if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - + if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); + if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); + if (checkIfNoneMatch(req, res, etag)) return; - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); - const text = await streamToString(data.Body); - + let text; + try { + const data = await readS3TextObject(s3Key); + text = data.body; + } catch (e) { + if (e?.code === 'NoSuchKey') { + return res.json(transform(defaultValue)); + } + throw e; + } + const result = transform(text); res.json(result); } catch (error) { - if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json(transform(defaultValue)); - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } + console.error(error); + return sendError(res, 500, 'Error reading storage', 'E_STORAGE'); } } @@ -103,4 +90,3 @@ module.exports = { getS3JsonWithHeaders, getS3TextWithHeaders, }; -